feat: add Mustache template support for prompts (#9876)

* Add MustachePromptModal component for handling Mustache template prompts

- Implement MustachePromptModal component with support for editing and validating Mustache templates.
- Integrate with alert store for success, error, and notice alerts.
- Utilize Mustache library for parsing and highlighting template variables.
- Include UI components like Textarea, Badge, and Button for user interaction.
- Add functionality to handle variable checking and prompt validation via API.

* Add support for Mustache templates in prompt components and modals

- Introduced `MustachePromptModal` for handling Mustache-specific prompts.
- Updated `PromptAreaComponent` to conditionally render `MustachePromptModal` based on the `mustache` prop.
- Enhanced `parameterRenderComponent` to recognize and handle "mustache" type templates.
- Modified `varHighlightHTML` utility to optionally add curly braces around variable names.
- Extended `usePostValidatePrompt` API call to include a `mustache` flag.
- Updated component types to include `mustache` and `addCurlyBraces` options.

* Add mustache and @types/mustache dependencies to frontend package files

* Add support for mustache template format in message schema and input mixin

- Updated `format_text` and `from_template_and_variables` methods to accept a `template_format` parameter, allowing for different template formats.
- Modified `ChatPromptTemplate.from_messages` to include `template_format` parameter.
- Added `MUSTACHE_PROMPT` to the `InputTypes` enum in `input_mixin.py`.

* Add 'mustache' boolean field to API request model in base.py

* Add MustachePromptComponent and enhance prompt validation for mustache templates

- Introduce `MustachePromptComponent` class to create prompt templates with dynamic variables using mustache syntax.
- Add `build_prompt`, `_update_template`, `post_code_processing`, and `_get_fallback_input` methods to handle prompt creation and processing.
- Update `validate_prompt` function in `api_utils.py` to support mustache template variable extraction.
- Import `mustache_template_vars` for handling mustache-specific template variables.

* chore: update package dependencies in package.json and package-lock.json

- Bump @types/mustache from 4.2.5 to 4.2.6
- Add @xyflow/react at version 12.3.6
- Update ace-builds from 1.35.0 to 1.41.0
- Add moment-timezone at version 0.5.48
- Update mustache version to 4.2.0
- Add rehype-raw at version 6.1.1
- Remove unused @napi-rs/nice dependencies from package-lock.json

* feat: add 'mustache' to DIRECT_TYPES in constants.py

* fix: update field_type for template in MustachePromptComponent to MUSTACHE_PROMPT

* feat: add MustachePromptAreaComponent for rendering mustache prompts with enhanced styling and functionality

* refactor: update import paths for common components in mustachePromptModal

* feat: integrate MustachePromptAreaComponent into ParameterRenderComponent for mustache prompt rendering

* test: add comprehensive tests for MustachePromptComponent and template processing

- Introduced integration tests for MustachePromptComponent covering various scenarios including basic functionality, multiple variables, missing variables, and complex logic.
- Added unit tests for Message class to validate mustache template processing, including handling of special characters, lists, and conditional logic.
- Ensured robust coverage for edge cases such as empty templates and missing variables.

* test: add unit tests for ParameterRenderComponent and MustachePromptAreaComponent

- Introduced TypeScript tests for ParameterRenderComponent to validate props interface.
- Added comprehensive tests for MustachePromptAreaComponent focusing on mustache variable highlighting and props validation.
- Ensured robust coverage for various input scenarios, including handling of special characters and complex mustache variables.

* test: refine MustachePromptAreaComponent highlighting logic and add validation tests

- Updated the highlighting logic to only support simple mustache variables ({{variable_name}}) and exclude complex syntax.
- Enhanced unit tests to validate that complex mustache variables, variables with spaces, and invalid characters are not highlighted.
- Added tests for various edge cases, including variables starting with numbers and those containing special operators.
- Ensured comprehensive coverage for the MustachePromptAreaComponent's behavior in handling mustache templates.

* feat: implement secure mustache rendering in Message class

- Added a new utility module for mustache template security, including validation and safe rendering functions.
- Updated the Message class to support mustache template formatting using the new secure renderer.
- Ensured that only simple variable substitutions are allowed in mustache templates to enhance security.

* test: enhance MustachePromptComponent tests for variable handling and security

- Updated tests for MustachePromptComponent to ensure only simple variables are processed and complex syntax is rejected.
- Added new tests for validating mustache templates, including checks for invalid variable names and complex syntax.
- Introduced integration tests for mustache security utilities, ensuring robust validation and safe rendering of templates.
- Enhanced coverage for various edge cases, including nested objects and handling of None values.

* feat: add mustache prompt dialog subtitle and update modal reference

- Introduced a new constant for the mustache prompt dialog subtitle to guide users on using double curly brackets for variable introduction.
- Updated the MustachePromptModal to utilize the new subtitle constant, enhancing clarity and user experience.

* refactor: update display name in MustachePromptComponent for clarity

- Changed the display name from "Mustache Prompt" to "Prompt" to simplify the component's identification and improve user experience.

* refactor: move MustachePromptComponent to lfx

- Introduced MustachePromptComponent to create prompt templates with dynamic variables.
- Updated imports and exports in the processing module to include the new component.
- Implemented methods for building prompts and updating templates, enhancing flexibility in prompt generation.

* refactor: update input variable extraction logic in validate_prompt

- Adjusted the order of input variable extraction in the validate_prompt function to ensure correct handling of Mustache templates.
- Enhanced the process_prompt_template function to accept an is_mustache parameter, improving flexibility in template validation.

* refactor: enhance MustachePromptComponent with template validation and async updates

- Updated imports to reflect the new module structure under lfx.
- Added a validation step for Mustache templates to improve security.
- Refactored the post_code_processing method to an async update_frontend_node method for better performance and compatibility with async workflows.

* refactor: enhance Message class template handling with format support

- Updated the from_template_and_variables and from_template methods to accept a template_format parameter, improving flexibility in template processing.
- Ensured backward compatibility with previous versions while enhancing the functionality for template formatting options.

* feat: add 'mustache' to DIRECT_TYPES in constants

- Introduced 'mustache' as a new type in the DIRECT_TYPES list to support enhanced template processing capabilities.
- This addition aligns with recent updates to template handling and validation in the codebase.

* test: add unit tests for MustachePromptComponent

- Introduced comprehensive unit tests for the MustachePromptComponent, covering template variable extraction, handling of multiple variables, dot notation, and rejection of complex syntax.
- Added async tests for the build_prompt method to ensure correct message generation with various templates and variable scenarios.
- Enhanced test coverage to validate the update_frontend_node method's processing of templates.

* refactor: remove MustachePromptComponent and related tests

- Deleted the MustachePromptComponent from the processing module, streamlining the codebase by removing unused components.
- Removed associated unit tests to maintain consistency and reduce clutter in the test suite.
- Updated imports and exports in the processing module to reflect these changes.

* refactor: enhance PromptComponent with mode selection and template validation

- Updated the PromptComponent to include a mode input for selecting variable syntax, allowing for both Mustache and f-string formats.
- Implemented logic in the update_build_config method to adjust the template field type based on the selected mode.
- Added validation for Mustache templates to ensure security during template processing.
- Refactored the build_prompt and update_frontend_node methods to accommodate the new mode functionality and maintain compatibility with async workflows.

* test: add unit tests for PromptComponent template modes

- Introduced comprehensive unit tests for the PromptComponent, covering both f-string and Mustache modes.
- Validated mode switching, template updates, and prompt building for various scenarios, including handling of multiple variables and rejection of complex syntax.
- Ensured robust testing of the update_build_config and update_frontend_node methods to maintain functionality across different template formats.

* feat: add MustachePromptInput class to support Mustache template processing

- Introduced MustachePromptInput as a subclass of PromptInput, enabling the use of Mustache syntax for prompt generation.
- Updated InputTypes to include MustachePromptInput, enhancing the flexibility of input handling in the codebase.

* refactor: update prompt components to support mode selection and enhanced metadata

- Changed the icon for various prompt components from braces to prompts for better visual representation.
- Updated metadata to include a code hash and dependencies for improved tracking and management.
- Introduced a mode input for selecting variable syntax (f-string or Mustache), enhancing flexibility in template processing.
- Refactored prompt component code to accommodate the new mode functionality, ensuring compatibility with async workflows and maintaining robust template validation.

* feat: add "mustache" to LANGFLOW_SUPPORTED_TYPES in constants

* feat: include mustache flag in post_validate_prompt function

* fix: prevent retries on validation errors in usePostValidatePrompt mutation

* fix: simplify mustache variable validation to only allow simple variable names

* fix: update mustache variable regex to only match simple variable names

* fix: enhance template processing and validation for mustache mode

- Improved handling of template variables when switching modes, ensuring old fields are cleaned up.
- Added error handling during template validation to allow component creation even if validation fails.
- Streamlined the process of re-validating and processing templates to maintain compatibility with existing functionality.

* fix: refine input variable extraction for mustache and f-string templates

- Enhanced the validation process to differentiate between mustache and f-string variables.
- Updated the logic to ensure only relevant variables are retained based on the template syntax.
- Improved error messaging for better clarity on invalid input variables.

* chore: update component index

* test: add unit tests for mustache prompt component functionality

- Implemented tests for mode switching between f-string and mustache syntax.
- Verified that old fields are cleaned up when switching modes.
- Added checks for validation errors and ensured component functionality remains intact.
- Included tests for mixed syntax handling and the absence of connection handles for mustache fields.

* test: remove mustache prompt component unit tests

- Deleted the unit tests for the mustache prompt component, which included functionality for mode switching, validation error handling, and mixed syntax scenarios.
- This cleanup is part of a broader refactor to streamline test coverage and focus on essential components.

* chore: update package-lock.json to add new dependencies and remove unused ones

- Added "@radix-ui/react-radio-group" and "use-stick-to-bottom" dependencies.
- Removed "@smakss/react-scroll-direction" dependency.
- Updated various other dependencies to their latest versions for improved stability and performance.

* test: update mustache security tests for variable naming conventions

- Modified tests to use underscore notation for variable names instead of dot notation, ensuring compliance with the updated validation rules.
- Removed tests for dot notation and nested object properties, which are no longer supported.
- Added new tests for rendering with multiple variables and underscore variable names, enhancing coverage for the updated functionality.

* update component index

* test: update mustache template processing tests

- Removed tests for dot notation and nested object properties, aligning with the updated validation rules.
- Modified existing tests to ensure dot notation is rejected, enhancing security checks for mustache templates.
- This update streamlines the test suite to focus on supported variable naming conventions.

* test: remove mustache prompt integration and unit tests

- Deleted integration and unit tests for the MustachePromptComponent, including various scenarios for template processing and variable handling.
- This cleanup is part of an effort to streamline the test suite and focus on essential components, aligning with recent changes in validation rules and functionality.

* test: refine mustache template variable regex in tests

- Updated the regex in the MustachePromptAreaComponent tests to only match simple mustache variables, removing support for complex syntax.
- This change aligns with the recent updates to validation rules and enhances the clarity of the test cases.

* test: update mustache prompt component tests for variable highlighting

- Modified tests to ensure that complex mustache syntax is not highlighted, aligning with the updated validation rules.
- Renamed a test to clarify that dot notation variables should not be highlighted, reflecting the current functionality.
- These changes enhance the accuracy and clarity of the test cases for mustache template processing.

* refactor: update addLegacyComponents function to improve selector checks

- Replaced the expect assertion with a waitForSelector call to ensure the sidebar legacy switch is checked, enhancing reliability in tests.
- Updated import statement for Page from "@playwright/test" for consistency with current practices.

* fix playwright imports

* fix: Add missing newline at end of package.json and correct syntax in test utility functions

* fix: Update placeholder text and prompt dialog subtitle in Mustache component

* feat: Enhance PromptComponent to support Mustache syntax and dynamic template updates

* test: Add tests for double brackets toggle in Prompt component

- Add Playwright E2E tests for double brackets variable extraction
- Add Python unit tests for use_double_brackets BoolInput
- Test single and multiple variable extraction with {{var}} syntax
- Verify toggle persistence and input field creation

* chore: Update starter projects with use_double_brackets field

Update all starter project templates to include the new
use_double_brackets field in Prompt components

* chore: Update package-lock.json to reflect dependency changes

* [autofix.ci] apply automated fixes

* Fix: enhance mustache security by adding triple braces pattern and improving safe rendering documentation

* Fix: add logging for template validation failures in PromptComponent

* Fix: update mustache interface to make 'mustache' optional and ensure default value is false; add mustache security utilities for template validation

* Fix: improve error handling in validate_prompt for mustache template parsing

* Fix: refactor variable extraction in MustachePromptModal for improved regex matching and state management

* chore: update package-lock.json to add peer dependencies and remove unused packages

- Added peer: true to several dependencies to indicate peer dependency requirements.
- Removed unused dependencies related to @napi-rs/nice and its variants.

* feat: add unit tests for MustachePromptAreaComponent and MustachePromptModal

* update package lock

* update package lock

* [autofix.ci] apply automated fixes

* update index

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Gabriel Luiz Freitas Almeida
2026-01-08 11:59:47 -03:00
committed by GitHub
parent 02cacd773e
commit 7c19477bc2
55 changed files with 4934 additions and 391 deletions

View File

@ -1403,7 +1403,7 @@
"filename": "src/frontend/src/constants/constants.ts",
"hashed_secret": "19a2fbd0dd38b4097f419c962342ef5e109eab07",
"is_verified": false,
"line_number": 751,
"line_number": 755,
"is_secret": false
},
{
@ -1411,7 +1411,7 @@
"filename": "src/frontend/src/constants/constants.ts",
"hashed_secret": "3806954324550e26ef5de85d007f1746825a073c",
"is_verified": false,
"line_number": 752,
"line_number": 756,
"is_secret": false
},
{
@ -1419,7 +1419,7 @@
"filename": "src/frontend/src/constants/constants.ts",
"hashed_secret": "c04f8fbf55c9096907a982750b1c6b0e4c1dd658",
"is_verified": false,
"line_number": 926,
"line_number": 930,
"is_secret": false
}
],
@ -1528,5 +1528,5 @@
}
]
},
"generated_at": "2025-12-17T22:10:09Z"
"generated_at": "2026-01-08T13:01:29Z"
}

View File

@ -25,6 +25,7 @@ class ValidatePromptRequest(BaseModel):
template: str
custom_fields: dict | None = None
frontend_node: FrontendNodeRequest | None = None
mustache: bool = False
# Build ValidationResponse class for {"imports": {"errors": []}, "function": {"errors": []}}

View File

@ -40,6 +40,7 @@ async def post_validate_prompt(
name=prompt_request.name,
custom_fields=prompt_request.frontend_node.custom_fields,
frontend_node_template=prompt_request.frontend_node.template,
is_mustache=prompt_request.mustache,
)
return PromptValidationResponse(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
"""Backwards compatibility module for langflow.utils.mustache_security."""
from lfx.utils.mustache_security import * # noqa: F403

View File

@ -0,0 +1,119 @@
"""Tests for mustache security utilities."""
import pytest
from langflow.utils.mustache_security import safe_mustache_render, validate_mustache_template
class TestMustacheSecurity:
"""Test mustache security functions."""
def test_validate_accepts_simple_variables(self):
"""Test that simple variables are accepted."""
# Should not raise
validate_mustache_template("Hello {{name}}!")
validate_mustache_template("{{user_name}} - {{user_email}}")
validate_mustache_template("Price: {{price_100}}")
validate_mustache_template("")
validate_mustache_template("No variables here")
def test_validate_rejects_complex_syntax(self):
"""Test that complex mustache syntax is rejected."""
# Conditionals
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{#if}}content{{/if}}")
# Inverted sections
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{^empty}}not empty{{/empty}}")
# Unescaped
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{&html}}")
# Partials
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{>header}}")
# Comments
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{!comment}}")
# Current context
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{.}}")
def test_validate_rejects_invalid_variable_names(self):
"""Test that invalid variable names are rejected."""
# Spaces in variable names
with pytest.raises(ValueError, match="Invalid mustache variable"):
validate_mustache_template("{{ name with spaces }}")
# Starting with numbers
with pytest.raises(ValueError, match="Invalid mustache variable"):
validate_mustache_template("{{123invalid}}")
# Special characters
with pytest.raises(ValueError, match="Invalid mustache variable"):
validate_mustache_template("{{price-$100}}")
# Empty variables
with pytest.raises(ValueError, match="Invalid mustache variable"):
validate_mustache_template("{{}}")
# Dot notation (not supported)
with pytest.raises(ValueError, match="Invalid mustache variable"):
validate_mustache_template("{{user.name}}")
def test_safe_render_simple_variables(self):
"""Test safe rendering of simple variables."""
template = "Hello {{name}}! You are {{age}} years old."
variables = {"name": "Alice", "age": 25}
result = safe_mustache_render(template, variables)
assert result == "Hello Alice! You are 25 years old."
def test_safe_render_missing_variables(self):
"""Test rendering with missing variables."""
template = "Hello {{name}}! Your score is {{score}}."
variables = {"name": "Charlie"}
result = safe_mustache_render(template, variables)
assert result == "Hello Charlie! Your score is ."
def test_safe_render_none_values(self):
"""Test rendering with None values."""
template = "Name: {{name}}, Age: {{age}}"
variables = {"name": None, "age": None}
result = safe_mustache_render(template, variables)
assert result == "Name: , Age: "
def test_safe_render_numeric_values(self):
"""Test rendering with numeric values."""
template = "Price: ${{price}}, Quantity: {{qty}}"
variables = {"price": 19.99, "qty": 3}
result = safe_mustache_render(template, variables)
assert result == "Price: $19.99, Quantity: 3"
def test_safe_render_rejects_complex_syntax(self):
"""Test that safe_render rejects complex syntax."""
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
safe_mustache_render("{{#if}}test{{/if}}", {"if": True})
def test_safe_render_multiple_variables(self):
"""Test rendering with multiple variables."""
template = "{{greeting}} {{name}}, welcome to {{place}}!"
variables = {"greeting": "Hello", "name": "Alice", "place": "Langflow"}
result = safe_mustache_render(template, variables)
assert result == "Hello Alice, welcome to Langflow!"
def test_safe_render_underscore_variables(self):
"""Test rendering with underscore variable names."""
template = "Private: {{_private_var}}, Public: {{public_var}}"
variables = {"_private_var": "secret", "public_var": "visible"}
result = safe_mustache_render(template, variables)
assert result == "Private: secret, Public: visible"
def test_safe_render_empty_string_values(self):
"""Test rendering with empty string values."""
template = "Start{{middle}}End"
variables = {"middle": ""}
result = safe_mustache_render(template, variables)
assert result == "StartEnd"

View File

@ -35,6 +35,7 @@
"@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/react-query": "^5.49.2",
"@types/axios": "^0.14.0",
"@types/mustache": "^4.2.6",
"@xyflow/react": "^12.3.6",
"ace-builds": "^1.41.0",
"ag-grid-community": "^32.3.9",
@ -58,6 +59,7 @@
"lucide-react": "^0.503.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"mustache": "^4.2.0",
"nanoid": "^5.1.6",
"openseadragon": "^4.1.1",
"p-debounce": "^4.0.0",
@ -206,7 +208,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -856,9 +857,9 @@
}
},
"node_modules/@borewit/text-codec": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz",
"integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==",
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
"integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
"dev": true,
"license": "MIT",
"funding": {
@ -1065,7 +1066,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz",
"integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@chakra-ui/shared-utils": "2.0.5",
"csstype": "^3.1.2",
@ -1077,7 +1077,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz",
"integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@chakra-ui/color-mode": "2.2.0",
"@chakra-ui/object-utils": "2.1.0",
@ -1184,7 +1183,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@ -1221,17 +1219,15 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.3.tgz",
"integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.5",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.5.tgz",
"integrity": "sha512-A2MwwJB5vC0UeOKHTsB0ZX19DH3/0KMaPgz45r0wd+SRwjk8x3KvYVQ/lYHRdZXxE59H0eUea1ANPqIPxX7QTw==",
"version": "6.39.8",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.8.tgz",
"integrity": "sha512-1rASYd9Z/mE3tkbC9wInRlCNyCkSn+nLsiQKZhEDUUJiUfs/5FHDpCUDaQpoTIaNGeDc6/bhaEAyLmeEucEFPw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@ -1327,7 +1323,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1351,7 +1346,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -5446,9 +5440,9 @@
]
},
"node_modules/@sinclair/typebox": {
"version": "0.34.41",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
"integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==",
"version": "0.34.46",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.46.tgz",
"integrity": "sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==",
"dev": true,
"license": "MIT"
},
@ -5492,16 +5486,16 @@
"license": "ISC"
},
"node_modules/@storybook/addon-docs": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.10.tgz",
"integrity": "sha512-PSJVtawnGNrEkeLJQn9TTdeqrtDij8onvmnFtfkDaFG5IaUdQaLX9ibJ4gfxYakq+BEtlCcYiWErNJcqDrDluQ==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.11.tgz",
"integrity": "sha512-Jwm291Fhim2eVcZIVlkG1B2skb0ZI9oru6nqMbJxceQZlvZmcIa4oxvS1oaMTKw2DJnCv97gLm57P/YvRZ8eUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.1.10",
"@storybook/csf-plugin": "10.1.11",
"@storybook/icons": "^2.0.0",
"@storybook/react-dom-shim": "10.1.10",
"@storybook/react-dom-shim": "10.1.11",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@ -5511,13 +5505,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.1.10"
"storybook": "^10.1.11"
}
},
"node_modules/@storybook/addon-links": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.1.10.tgz",
"integrity": "sha512-SVKFDb14mne16QMGkmOEk+T4NLvCuFJJ1ecebQ01cPiG5gM72LhzYkAro717Aizd6owyMqcWs0Rsfwl09qi5zA==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.1.11.tgz",
"integrity": "sha512-PEC+Fn3fyBOlMlCcLX+AUunrQMcH7MEfiFtPkp7QnjfMGwBIyzCjgVxM2OyKyIslQnB1So1pY98uTI26fXz/yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5529,7 +5523,7 @@
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.1.10"
"storybook": "^10.1.11"
},
"peerDependenciesMeta": {
"react": {
@ -5538,13 +5532,13 @@
}
},
"node_modules/@storybook/builder-vite": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.10.tgz",
"integrity": "sha512-6m6zOyDhHLynv3lvkH70s1YoIkIFPhbpGsBKvHchRLrZLe8hCPeafIFLfZRPoD4yIPwBS6rWbjMsSvBMFlR+ag==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.11.tgz",
"integrity": "sha512-MMD09Ap7FyzDfWG961pkIMv/w684XXe1bBEi+wCEpHxvrgAd3j3A9w/Rqp9Am2uRDPCEdi1QgSzS3SGW3aGThQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/csf-plugin": "10.1.10",
"@storybook/csf-plugin": "10.1.11",
"@vitest/mocker": "3.2.4",
"ts-dedent": "^2.0.0"
},
@ -5553,14 +5547,14 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.1.10",
"storybook": "^10.1.11",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@storybook/csf-plugin": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.10.tgz",
"integrity": "sha512-2dri4TRU8uuj/skmx/ZBw+GnnXf8EZHiMDMeijVRdBQtYFWPeoYzNIrGRpNfbuGpnDP0dcxrqti/TsedoxwFkA==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.11.tgz",
"integrity": "sha512-Ant0NhgqHKzQsseeVTSetZCuDHHs0W2HRkHt51Kg/sUl0T/sDtfVA+fWZT8nGzGZqYSFkxqYPWjauPmIhPtaRw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5573,7 +5567,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "^10.1.10",
"storybook": "^10.1.11",
"vite": "*",
"webpack": "*"
},
@ -5611,14 +5605,14 @@
}
},
"node_modules/@storybook/react": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.1.10.tgz",
"integrity": "sha512-9Rpr8/wX0p5/EaulrxpqrjKjhGaA/Ab9HgxzTqs2Shz0gvMAQHoiRnTEp7RCCkP49ruFYnIp0yGRSovu03LakQ==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.1.11.tgz",
"integrity": "sha512-rmMGmEwBaM2YpB8oDk2moM0MNjNMqtwyoPPZxjyruY9WVhYca8EDPGKEdRzUlb4qZJsTgLi7VU4eqg6LD/mL3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.1.10",
"@storybook/react-dom-shim": "10.1.11",
"react-docgen": "^8.0.2"
},
"funding": {
@ -5628,7 +5622,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.1.10",
"storybook": "^10.1.11",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@ -5638,9 +5632,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.10.tgz",
"integrity": "sha512-9pmUbEr1MeMHg9TG0c2jVUfHWr2AA86vqZGphY/nT6mbe/rGyWtBl5EnFLrz6WpI8mo3h+Kxs6p2oiuIYieRtw==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.11.tgz",
"integrity": "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -5650,20 +5644,20 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.1.10"
"storybook": "^10.1.11"
}
},
"node_modules/@storybook/react-vite": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.1.10.tgz",
"integrity": "sha512-6kE4/88YuwO07P0DR6caKNDNvCB/VnpimPmj4Jv6qmqrBgnoOOiXHIKyHJD+EjNyrbbwv4ygG01RVEajpjQaDA==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.1.11.tgz",
"integrity": "sha512-qh1BCD25nIoiDfqwha+qBkl7pcG4WuzM+c8tsE63YEm8AFIbNKg5K8lVUoclF+4CpFz7IwBpWe61YUTDfp+91w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.3",
"@rollup/pluginutils": "^5.0.2",
"@storybook/builder-vite": "10.1.10",
"@storybook/react": "10.1.10",
"@storybook/builder-vite": "10.1.11",
"@storybook/react": "10.1.11",
"empathic": "^2.0.0",
"magic-string": "^0.30.0",
"react-docgen": "^8.0.0",
@ -5677,7 +5671,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.1.10",
"storybook": "^10.1.11",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
@ -6017,13 +6011,12 @@
}
},
"node_modules/@swc/core": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.7.tgz",
"integrity": "sha512-kTGB8XI7P+pTKW83tnUEDVP4zduF951u3UAOn5eTi0vyW6MvL56A3+ggMdfuVFtDI0/DsbSzf5z34HVBbuScWw==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz",
"integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
@ -6036,16 +6029,16 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.7",
"@swc/core-darwin-x64": "1.15.7",
"@swc/core-linux-arm-gnueabihf": "1.15.7",
"@swc/core-linux-arm64-gnu": "1.15.7",
"@swc/core-linux-arm64-musl": "1.15.7",
"@swc/core-linux-x64-gnu": "1.15.7",
"@swc/core-linux-x64-musl": "1.15.7",
"@swc/core-win32-arm64-msvc": "1.15.7",
"@swc/core-win32-ia32-msvc": "1.15.7",
"@swc/core-win32-x64-msvc": "1.15.7"
"@swc/core-darwin-arm64": "1.15.8",
"@swc/core-darwin-x64": "1.15.8",
"@swc/core-linux-arm-gnueabihf": "1.15.8",
"@swc/core-linux-arm64-gnu": "1.15.8",
"@swc/core-linux-arm64-musl": "1.15.8",
"@swc/core-linux-x64-gnu": "1.15.8",
"@swc/core-linux-x64-musl": "1.15.8",
"@swc/core-win32-arm64-msvc": "1.15.8",
"@swc/core-win32-ia32-msvc": "1.15.8",
"@swc/core-win32-x64-msvc": "1.15.8"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@ -6057,9 +6050,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.7.tgz",
"integrity": "sha512-+hNVUfezUid7LeSHqnhoC6Gh3BROABxjlDNInuZ/fie1RUxaEX4qzDwdTgozJELgHhvYxyPIg1ro8ibnKtgO4g==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.8.tgz",
"integrity": "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==",
"cpu": [
"arm64"
],
@ -6074,9 +6067,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.7.tgz",
"integrity": "sha512-ZAFuvtSYZTuXPcrhanaD5eyp27H8LlDzx2NAeVyH0FchYcuXf0h5/k3GL9ZU6Jw9eQ63R1E8KBgpXEJlgRwZUQ==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.8.tgz",
"integrity": "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==",
"cpu": [
"x64"
],
@ -6091,9 +6084,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.7.tgz",
"integrity": "sha512-K3HTYocpqnOw8KcD8SBFxiDHjIma7G/X+bLdfWqf+qzETNBrzOub/IEkq9UaeupaJiZJkPptr/2EhEXXWryS/A==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.8.tgz",
"integrity": "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==",
"cpu": [
"arm"
],
@ -6108,9 +6101,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.7.tgz",
"integrity": "sha512-HCnVIlsLnCtQ3uXcXgWrvQ6SAraskLA9QJo9ykTnqTH6TvUYqEta+TdTdGjzngD6TOE7XjlAiUs/RBtU8Z0t+Q==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.8.tgz",
"integrity": "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==",
"cpu": [
"arm64"
],
@ -6125,9 +6118,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.7.tgz",
"integrity": "sha512-/OOp9UZBg4v2q9+x/U21Jtld0Wb8ghzBScwhscI7YvoSh4E8RALaJ1msV8V8AKkBkZH7FUAFB7Vbv0oVzZsezA==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.8.tgz",
"integrity": "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==",
"cpu": [
"arm64"
],
@ -6142,9 +6135,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.7.tgz",
"integrity": "sha512-VBbs4gtD4XQxrHuQ2/2+TDZpPQQgrOHYRnS6SyJW+dw0Nj/OomRqH+n5Z4e/TgKRRbieufipeIGvADYC/90PYQ==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.8.tgz",
"integrity": "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==",
"cpu": [
"x64"
],
@ -6159,9 +6152,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.7.tgz",
"integrity": "sha512-kVuy2unodso6p0rMauS2zby8/bhzoGRYxBDyD6i2tls/fEYAE74oP0VPFzxIyHaIjK1SN6u5TgvV9MpyJ5xVug==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.8.tgz",
"integrity": "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==",
"cpu": [
"x64"
],
@ -6176,9 +6169,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.7.tgz",
"integrity": "sha512-uddYoo5Xmo1XKLhAnh4NBIyy5d0xk33x1sX3nIJboFySLNz878ksCFCZ3IBqrt1Za0gaoIWoOSSSk0eNhAc/sw==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.8.tgz",
"integrity": "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==",
"cpu": [
"arm64"
],
@ -6193,9 +6186,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.7.tgz",
"integrity": "sha512-rqq8JjNMLx3QNlh0aPTtN/4+BGLEHC94rj9mkH1stoNRf3ra6IksNHMHy+V1HUqElEgcZyx+0yeXx3eLOTcoFw==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.8.tgz",
"integrity": "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==",
"cpu": [
"ia32"
],
@ -6210,9 +6203,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.7.tgz",
"integrity": "sha512-4BK06EGdPnuplgcNhmSbOIiLdRgHYX3v1nl4HXo5uo4GZMfllXaCyBUes+0ePRfwbn9OFgVhCWPcYYjMT6hycQ==",
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.8.tgz",
"integrity": "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==",
"cpu": [
"x64"
],
@ -6234,9 +6227,9 @@
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
@ -6266,9 +6259,9 @@
}
},
"node_modules/@tabler/icons": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.0.tgz",
"integrity": "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA==",
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
"integrity": "sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==",
"license": "MIT",
"funding": {
"type": "github",
@ -6276,12 +6269,12 @@
}
},
"node_modules/@tabler/icons-react": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.36.0.tgz",
"integrity": "sha512-sSZ00bEjTdTTskVFykq294RJq+9cFatwy4uYa78HcYBCXU1kSD1DIp5yoFsQXmybkIOKCjp18OnhAYk553UIfQ==",
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.36.1.tgz",
"integrity": "sha512-/8nOXeNeMoze9xY/QyEKG65wuvRhkT3q9aytaur6Gj8bYU2A98YVJyLc9MRmc5nVvpy+bRlrrwK/Ykr8WGyUWg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.36.0"
"@tabler/icons": ""
},
"funding": {
"type": "github",
@ -6326,9 +6319,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
"integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
"license": "MIT",
"funding": {
"type": "github",
@ -6336,12 +6329,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
"@tanstack/query-core": "5.90.16"
},
"funding": {
"type": "github",
@ -6352,12 +6345,12 @@
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.13",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz",
"integrity": "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==",
"version": "3.13.14",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.14.tgz",
"integrity": "sha512-WG0d7mBD54eA7dgA3+sO5csS0B49QKqM6Gy5Rf31+Oq/LTKROQSao9m2N/vz1IqVragOKU5t5k1LAcqh/DfTxw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.13"
"@tanstack/virtual-core": "3.13.14"
},
"funding": {
"type": "github",
@ -6369,9 +6362,9 @@
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.13",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz",
"integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==",
"version": "3.13.14",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.14.tgz",
"integrity": "sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ==",
"license": "MIT",
"funding": {
"type": "github",
@ -6384,7 +6377,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -7063,6 +7055,12 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/mustache": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz",
"integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
@ -7090,7 +7088,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -7101,7 +7098,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -7117,8 +7113,7 @@
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
@ -7798,7 +7793,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8427,7 +8421,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -8621,9 +8614,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001761",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
"integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"funding": [
{
"type": "opencollective",
@ -8764,7 +8757,6 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -8808,9 +8800,9 @@
}
},
"node_modules/cjs-module-lexer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz",
"integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
"dev": true,
"license": "MIT"
},
@ -9244,7 +9236,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9793,7 +9784,6 @@
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@ -10031,7 +10021,6 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@ -10203,9 +10192,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@ -11681,7 +11670,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@ -13418,7 +13406,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -13468,7 +13455,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -13508,7 +13494,6 @@
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.16.0"
}
@ -15118,6 +15103,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -15253,9 +15247,9 @@
}
},
"node_modules/normalize-url": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.0.tgz",
"integrity": "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz",
"integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -15826,7 +15820,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -15878,6 +15871,48 @@
"postcss": "^8.4.21"
}
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
@ -16232,7 +16267,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -16305,7 +16339,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -16336,7 +16369,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz",
"integrity": "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -16934,7 +16966,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -17459,8 +17490,7 @@
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
"integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.5.7",
@ -17575,12 +17605,11 @@
}
},
"node_modules/storybook": {
"version": "10.1.10",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.10.tgz",
"integrity": "sha512-oK0t0jEogiKKfv5Z1ao4Of99+xWw1TMUGuGRYDQS4kp2yyBsJQEgu7NI7OLYsCDI6gzt5p3RPtl1lqdeVLUi8A==",
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.11.tgz",
"integrity": "sha512-pKP5jXJYM4OjvNklGuHKO53wOCAwfx79KvZyOWHoi9zXUH5WVMFUe/ZfWyxXG/GTcj0maRgHGUjq/0I43r0dDQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^2.0.0",
@ -17939,9 +17968,9 @@
}
},
"node_modules/svelte": {
"version": "5.46.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz",
"integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==",
"version": "5.46.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz",
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@ -18003,9 +18032,9 @@
}
},
"node_modules/tabbable": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz",
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
@ -18023,7 +18052,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -18087,48 +18115,6 @@
"node": ">=10.13.0"
}
},
"node_modules/tailwindcss/node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
@ -18375,13 +18361,13 @@
}
},
"node_modules/token-types": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz",
"integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.1.0",
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
@ -18646,7 +18632,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -19157,7 +19142,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -20172,15 +20156,14 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"dev": true,
"license": "ISC",
"peerDependencies": {

View File

@ -33,6 +33,7 @@
"@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/react-query": "^5.49.2",
"@types/axios": "^0.14.0",
"@types/mustache": "^4.2.6",
"@xyflow/react": "^12.3.6",
"ace-builds": "^1.41.0",
"ag-grid-community": "^32.3.9",
@ -56,6 +57,7 @@
"lucide-react": "^0.503.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"mustache": "^4.2.0",
"nanoid": "^5.1.6",
"openseadragon": "^4.1.1",
"p-debounce": "^4.0.0",

View File

@ -0,0 +1,286 @@
import { render, screen } from "@testing-library/react";
import MustachePromptAreaComponent from "../index";
// Mock the MustachePromptModal component
jest.mock("@/modals/mustachePromptModal", () => {
return function MockMustachePromptModal({
children,
value,
setValue,
id,
}: {
children: React.ReactNode;
value: string;
setValue: (val: string) => void;
id: string;
}) {
return (
<div data-testid="mock-mustache-modal" data-value={value} data-id={id}>
{children}
</div>
);
};
});
// Mock IconComponent
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({ name, className }: { name: string; className?: string }) => (
<span data-testid={`icon-${name}`} className={className}>
{name}
</span>
),
}));
// Mock SanitizedHTMLWrapper
jest.mock("@/components/common/sanitizedHTMLWrapper", () => {
return function MockSanitizedHTMLWrapper({
content,
className,
}: {
content: string;
className?: string;
}) {
return (
<div
data-testid="sanitized-html"
className={className}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
};
});
describe("MustachePromptAreaComponent", () => {
const defaultProps = {
field_name: "template",
nodeClass: null,
handleOnNewValue: jest.fn(),
handleNodeClass: jest.fn(),
value: "",
disabled: false,
editNode: false,
id: "test-mustache-prompt",
readonly: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("rendering", () => {
it("should render with empty value and show placeholder", () => {
render(<MustachePromptAreaComponent {...defaultProps} />);
expect(
screen.getByText(/Type your prompt here using/),
).toBeInTheDocument();
});
it("should render with value containing text", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Hello, this is a test prompt."
/>,
);
expect(screen.getByTestId("sanitized-html")).toBeInTheDocument();
});
it("should highlight mustache variables in the content", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Hello {{name}}, welcome to {{place}}!"
/>,
);
const sanitizedHtml = screen.getByTestId("sanitized-html");
expect(sanitizedHtml.innerHTML).toContain("chat-message-highlight");
expect(sanitizedHtml.innerHTML).toContain("{{name}}");
expect(sanitizedHtml.innerHTML).toContain("{{place}}");
});
it("should escape HTML tags in content", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="<script>alert('xss')</script>"
/>,
);
const sanitizedHtml = screen.getByTestId("sanitized-html");
expect(sanitizedHtml.innerHTML).toContain("&lt;script&gt;");
expect(sanitizedHtml.innerHTML).not.toContain("<script>");
});
it("should preserve newlines as <br /> tags", () => {
// The component converts \n to <br /> internally before passing to SanitizedHTMLWrapper
// We test the component's transformation by checking the expected pattern
render(
<MustachePromptAreaComponent
{...defaultProps}
value={"Line 1\nLine 2\nLine 3"}
/>,
);
const sanitizedHtml = screen.getByTestId("sanitized-html");
// The value is transformed by the component before being passed to the wrapper
// The actual component renders <br /> but our mock receives the raw transformed content
expect(sanitizedHtml).toBeInTheDocument();
});
});
describe("editNode mode", () => {
it("should apply edit node classes when editNode is true", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
editNode={true}
value="test value"
/>,
);
const promptSpan = screen.getByTestId("test-mustache-prompt");
expect(promptSpan).toHaveClass("input-edit-node");
});
it("should apply normal classes when editNode is false", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
editNode={false}
value="test value"
/>,
);
const promptSpan = screen.getByTestId("test-mustache-prompt");
expect(promptSpan).toHaveClass("primary-input");
});
});
describe("disabled state", () => {
it("should show lock icon when disabled and no value", () => {
render(<MustachePromptAreaComponent {...defaultProps} disabled={true} />);
expect(screen.getByTestId("icon-lock")).toBeInTheDocument();
});
it("should show Braces icon when not disabled and no value", () => {
render(
<MustachePromptAreaComponent {...defaultProps} disabled={false} />,
);
expect(screen.getByTestId("icon-Braces")).toBeInTheDocument();
});
it("should not show icon when there is a value", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Some value"
disabled={false}
/>,
);
expect(screen.queryByTestId("icon-lock")).not.toBeInTheDocument();
expect(screen.queryByTestId("icon-Braces")).not.toBeInTheDocument();
});
it("should apply disabled class to prompt span when disabled and not editNode", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
disabled={true}
editNode={false}
value="test"
/>,
);
const promptSpan = screen.getByTestId("test-mustache-prompt");
expect(promptSpan).toHaveClass("disabled-state");
});
it("should apply pointer-events-none to wrapper when disabled", () => {
const { container } = render(
<MustachePromptAreaComponent {...defaultProps} disabled={true} />,
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass("pointer-events-none");
});
});
describe("modal integration", () => {
it("should pass correct props to MustachePromptModal", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="test value"
id="custom-id"
/>,
);
const modal = screen.getByTestId("mock-mustache-modal");
expect(modal).toHaveAttribute("data-value", "test value");
expect(modal).toHaveAttribute("data-id", "custom-id");
});
it("should render button inside modal trigger", () => {
render(<MustachePromptAreaComponent {...defaultProps} />);
const button = screen.getByTestId("button_open_mustache_prompt_modal");
expect(button).toBeInTheDocument();
});
});
describe("variable highlighting", () => {
it("should only highlight valid mustache variables", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Valid: {{name}}, Invalid: {{123invalid}} and {{with space}}"
/>,
);
const sanitizedHtml = screen.getByTestId("sanitized-html");
// Only {{name}} should be highlighted (valid variable)
expect(sanitizedHtml.innerHTML).toContain(
'<span class="chat-message-highlight">{{name}}</span>',
);
// Invalid patterns should NOT be highlighted
expect(sanitizedHtml.innerHTML).not.toContain(
'<span class="chat-message-highlight">{{123invalid}}</span>',
);
});
it("should highlight variables starting with underscore", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Value: {{_private}}"
/>,
);
const sanitizedHtml = screen.getByTestId("sanitized-html");
expect(sanitizedHtml.innerHTML).toContain(
'<span class="chat-message-highlight">{{_private}}</span>',
);
});
it("should highlight variables with numbers", () => {
render(
<MustachePromptAreaComponent
{...defaultProps}
value="Value: {{var123}}"
/>,
);
const sanitizedHtml = screen.getByTestId("sanitized-html");
expect(sanitizedHtml.innerHTML).toContain(
'<span class="chat-message-highlight">{{var123}}</span>',
);
});
});
});

View File

@ -0,0 +1,208 @@
// Tests for MustachePromptAreaComponent functionality
// Focus on testing the core logic without importing the full component
describe("MustachePromptAreaComponent", () => {
describe("mustache variable highlighting logic", () => {
// Test the core mustache highlighting logic that's used in the component
const applyMustacheHighlighting = (value: string) => {
return (
(typeof value === "string" ? value : "")
// escape HTML first
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// highlight only simple mustache variables {{variable_name}} - no complex syntax
.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (match, varName) => {
return `<span class="chat-message-highlight">{{${varName}}}</span>`;
})
// preserve new-lines
.replace(/\n/g, "<br />")
);
};
it("should highlight single mustache variable", () => {
const input = "Hello {{name}}!";
const result = applyMustacheHighlighting(input);
expect(result).toBe(
'Hello <span class="chat-message-highlight">{{name}}</span>!',
);
});
it("should highlight multiple mustache variables", () => {
const input = "Hello {{name}}, you are {{age}} years old.";
const result = applyMustacheHighlighting(input);
expect(result).toBe(
'Hello <span class="chat-message-highlight">{{name}}</span>, you are <span class="chat-message-highlight">{{age}}</span> years old.',
);
});
it("should escape HTML content", () => {
const input = "Content: <script>alert('test')</script>";
const result = applyMustacheHighlighting(input);
expect(result).toBe(
"Content: &lt;script&gt;alert('test')&lt;/script&gt;",
);
});
it("should preserve newlines", () => {
const input = "Line 1\nLine 2\nLine 3";
const result = applyMustacheHighlighting(input);
expect(result).toBe("Line 1<br />Line 2<br />Line 3");
});
it("should NOT highlight complex mustache syntax", () => {
const input = "{{#if user}}Hello {{name}}{{/if}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe(
'{{#if user}}Hello <span class="chat-message-highlight">{{name}}</span>{{/if}}',
);
});
it("should handle mixed content", () => {
const input =
"Hello {{name}}!\n<script>alert('test')</script>\nAge: {{age}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe(
'Hello <span class="chat-message-highlight">{{name}}</span>!<br />&lt;script&gt;alert(\'test\')&lt;/script&gt;<br />Age: <span class="chat-message-highlight">{{age}}</span>',
);
});
it("should handle empty string", () => {
const input = "";
const result = applyMustacheHighlighting(input);
expect(result).toBe("");
});
it("should handle string with no mustache variables", () => {
const input = "This is a plain string.";
const result = applyMustacheHighlighting(input);
expect(result).toBe("This is a plain string.");
});
it("should NOT highlight variables with spaces", () => {
const input = "Hello {{ name with spaces }}!";
const result = applyMustacheHighlighting(input);
expect(result).toBe("Hello {{ name with spaces }}!");
});
it("should NOT highlight variables with invalid characters", () => {
const input = "Value: {{price-$100}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("Value: {{price-$100}}");
});
it("should handle variables with underscores", () => {
const input = "Value: {{price_100}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe(
'Value: <span class="chat-message-highlight">{{price_100}}</span>',
);
});
it("should NOT highlight dot notation variables", () => {
const input = "Hello {{user.name}} from {{company.address.city}}";
const result = applyMustacheHighlighting(input);
// Dot notation is not supported, so should not be highlighted
expect(result).toBe("Hello {{user.name}} from {{company.address.city}}");
});
it("should reject variables starting with numbers", () => {
const input = "Value: {{123invalid}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("Value: {{123invalid}}");
});
it("should reject variables with hashtags (conditionals)", () => {
const input = "{{#each items}}Item{{/each}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("{{#each items}}Item{{/each}}");
});
it("should reject variables with forward slashes (closers)", () => {
const input = "{{/if}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("{{/if}}");
});
it("should reject variables with carets (inverted sections)", () => {
const input = "{{^isEmpty}}Not empty{{/isEmpty}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("{{^isEmpty}}Not empty{{/isEmpty}}");
});
it("should reject variables with ampersands (unescaped)", () => {
const input = "{{&unescaped}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("{{&unescaped}}");
});
it("should reject variables with dots at the start", () => {
const input = "{{.}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("{{.}}");
});
it("should reject empty variables", () => {
const input = "{{}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("{{}}");
});
it("should reject variables with special operators", () => {
const input = "{{>partial}} {{!comment}}";
const result = applyMustacheHighlighting(input);
expect(result).toBe("{{&gt;partial}} {{!comment}}");
});
});
describe("component props validation", () => {
it("should accept correct props type", () => {
const mockProps = {
id: "test-id",
value: "Hello {{name}}!",
editNode: false,
handleOnNewValue: jest.fn(),
disabled: false,
nodeClass: {
description: "Test component",
template: {},
display_name: "Test Component",
documentation: "Test component documentation",
},
handleNodeClass: jest.fn(),
nodeId: "test-node-id",
readonly: false,
field_name: "template",
};
// If this compiles without TypeScript errors, the types are correct
expect(mockProps).toBeDefined();
expect(mockProps.nodeClass.description).toBe("Test component");
expect(mockProps.nodeClass.display_name).toBe("Test Component");
expect(mockProps.nodeClass.documentation).toBe(
"Test component documentation",
);
expect(mockProps.value).toBe("Hello {{name}}!");
expect(mockProps.field_name).toBe("template");
});
it("should handle different value types", () => {
const propsWithEmptyValue = {
value: "",
field_name: "template",
disabled: false,
readonly: false,
};
const propsWithComplexValue = {
value: "Item: {{name}} - {{price}}",
field_name: "template",
disabled: false,
readonly: false,
};
expect(propsWithEmptyValue.value).toBe("");
expect(propsWithComplexValue.value).toContain("{{name}}");
expect(propsWithComplexValue.value).toContain("{{price}}");
});
});
});

View File

@ -0,0 +1,102 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import SanitizedHTMLWrapper from "@/components/common/sanitizedHTMLWrapper";
import MustachePromptModal from "@/modals/mustachePromptModal";
import { cn } from "../../../../../utils/utils";
import { Button } from "../../../../ui/button";
import { getPlaceholder } from "../../helpers/get-placeholder-disabled";
import type { InputProps, PromptAreaComponentType } from "../../types";
const promptContentClasses = {
base: "overflow-hidden text-clip whitespace-nowrap bg-background h-fit max-h-28",
editNode: "input-edit-node input-dialog py-2",
normal: "primary-input text-primary",
disabled: "disabled-state",
};
export default function MustachePromptAreaComponent({
field_name,
nodeClass,
handleOnNewValue,
handleNodeClass,
value,
disabled,
editNode = false,
id = "",
readonly = false,
}: InputProps<string, PromptAreaComponentType>): JSX.Element {
const coloredContent = (typeof value === "string" ? value : "")
// escape HTML first
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// highlight only simple mustache variables {{variable_name}} - no complex syntax
.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (match, varName) => {
return `<span class="chat-message-highlight">{{${varName}}}</span>`;
})
// preserve new-lines
.replace(/\n/g, "<br />");
const renderPromptText = () => (
<span
id={id}
data-testid={id}
className={cn(
promptContentClasses.base,
editNode ? promptContentClasses.editNode : promptContentClasses.normal,
disabled && !editNode && promptContentClasses.disabled,
)}
>
{value !== "" ? (
<SanitizedHTMLWrapper
className="m-0 whitespace-pre-wrap p-0 text-xs"
content={coloredContent}
suppressWarning={true}
/>
) : (
<span className="text-sm text-muted-foreground">
{getPlaceholder(
disabled,
"Type your prompt here using {{variable}}...",
)}
</span>
)}
</span>
);
const renderExternalLinkIcon = () =>
!value || value == "" ? (
<ForwardedIconComponent
name={disabled ? "lock" : "Braces"}
className={cn(
"icons-parameters-comp pointer-events-none absolute right-3 top-1/2 h-4 w-4 shrink-0 -translate-y-1/2",
disabled ? "text-placeholder-foreground" : "text-foreground",
)}
/>
) : (
<></>
);
return (
<div className={cn("w-full", disabled && "pointer-events-none")}>
<MustachePromptModal
id={id}
field_name={field_name}
readonly={readonly}
value={value}
setValue={(newValue) => handleOnNewValue({ value: newValue })}
nodeClass={nodeClass}
setNodeClass={handleNodeClass}
>
<Button
unstyled
className="w-full"
data-testid="button_open_mustache_prompt_modal"
>
<div className="relative w-full">
{renderPromptText()}
{renderExternalLinkIcon()}
</div>
</Button>
</MustachePromptModal>
</div>
);
}

View File

@ -0,0 +1,37 @@
// TypeScript-only test to verify component props interface
// This test doesn't run any actual component code to avoid Jest configuration issues
describe("ParameterRenderComponent Types", () => {
it("should accept correct props type", () => {
// This test verifies that the component accepts the correct props structure
const baseProps = {
handleOnNewValue: jest.fn(),
name: "test-field",
nodeId: "test-node-id",
editNode: false,
handleNodeClass: jest.fn(),
nodeClass: {
description: "Test component",
template: {},
display_name: "Test Component",
documentation: "Test component documentation",
},
disabled: false,
templateValue: "Hello {{name}}!",
};
// Test different template data types
const mustacheTemplateData = { type: "mustache", name: "template" };
const promptTemplateData = { type: "prompt", name: "template" };
// If this compiles without TypeScript errors, the types are correct
expect(baseProps).toBeDefined();
expect(baseProps.nodeClass.description).toBe("Test component");
expect(baseProps.nodeClass.display_name).toBe("Test Component");
expect(baseProps.nodeClass.documentation).toBe(
"Test component documentation",
);
expect(mustacheTemplateData.type).toBe("mustache");
expect(promptTemplateData.type).toBe("prompt");
});
});

View File

@ -17,6 +17,7 @@ import IntComponent from "./components/intComponent";
import KeypairListComponent from "./components/keypairListComponent";
import McpComponent from "./components/mcpComponent";
import MultiselectComponent from "./components/multiselectComponent";
import MustachePromptAreaComponent from "./components/mustachePromptComponent";
import PromptAreaComponent from "./components/promptComponent";
import QueryComponent from "./components/queryComponent";
import SortableListComponent from "./components/sortableListComponent";
@ -190,6 +191,15 @@ export function ParameterRenderComponent({
id={`promptarea_${id}`}
/>
);
case "mustache":
return (
<MustachePromptAreaComponent
{...baseInputProps}
readonly={!!nodeClass.flow}
field_name={name}
id={`mustachepromptarea_${id}`}
/>
);
case "code":
return <CodeAreaComponent {...baseInputProps} id={`codearea_${id}`} />;
case "table":

View File

@ -7,7 +7,7 @@ import {
import { customDefaultShortcuts } from "../customization/constants";
import type { languageMap } from "../types/components";
const getEnvVar = (key: string, defaultValue: any = undefined) => {
const getEnvVar = (key: string, defaultValue: unknown = undefined) => {
if (typeof process !== "undefined" && process.env) {
return process.env[key] ?? defaultValue;
}
@ -172,6 +172,9 @@ export const CODE_DICT_DIALOG_SUBTITLE =
export const PROMPT_DIALOG_SUBTITLE =
"Create your prompt. Prompts can help guide the behavior of a Language Model. Use curly brackets {} to introduce variables.";
export const MUSTACHE_PROMPT_DIALOG_SUBTITLE =
"Create your prompt. Prompts can help guide the behavior of a Language Model. Use double curly brackets {{}} to introduce variables.";
export const CHAT_CANNOT_OPEN_TITLE = "Chat Cannot Open";
export const CHAT_CANNOT_OPEN_DESCRIPTION = "This is not a chat flow.";
@ -673,6 +676,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
"float",
"code",
"prompt",
"mustache",
"file",
"int",
"dict",

View File

@ -13,6 +13,7 @@ interface IPostValidatePrompt {
name: string;
template: string;
frontend_node: APIClassType;
mustache?: boolean;
}
export const usePostValidatePrompt: useMutationFunctionType<
@ -32,6 +33,7 @@ export const usePostValidatePrompt: useMutationFunctionType<
name: payload.name,
template: payload.template,
frontend_node: payload.frontend_node,
mustache: payload.mustache ?? false,
},
);
@ -42,7 +44,10 @@ export const usePostValidatePrompt: useMutationFunctionType<
PromptTypeAPI,
ResponseErrorDetailAPI,
IPostValidatePrompt
> = mutate(["usePostValidatePrompt"], postValidatePromptFn, options);
> = mutate(["usePostValidatePrompt"], postValidatePromptFn, {
...options,
retry: 0, // Don't retry validation errors - they're not transient
});
return mutation;
};

View File

@ -0,0 +1,524 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MustachePromptModal from "../index";
// Mock the API hook
const mockMutate = jest.fn();
jest.mock("@/controllers/API/queries/nodes/use-post-validate-prompt", () => ({
usePostValidatePrompt: () => ({
mutate: mockMutate,
}),
}));
// Mock alert store
const mockSetSuccessData = jest.fn();
const mockSetErrorData = jest.fn();
const mockSetNoticeData = jest.fn();
interface AlertState {
setSuccessData: typeof mockSetSuccessData;
setErrorData: typeof mockSetErrorData;
setNoticeData: typeof mockSetNoticeData;
}
jest.mock("@/stores/alertStore", () => ({
__esModule: true,
default: <T,>(selector: (state: AlertState) => T): T => {
const state: AlertState = {
setSuccessData: mockSetSuccessData,
setErrorData: mockSetErrorData,
setNoticeData: mockSetNoticeData,
};
return selector(state);
},
}));
// Mock IconComponent
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({ name, className }: { name: string; className?: string }) => (
<span data-testid={`icon-${name}`} className={className}>
{name}
</span>
),
}));
// Mock SanitizedHTMLWrapper
jest.mock("@/components/common/sanitizedHTMLWrapper", () => {
const React = require("react");
return React.forwardRef(function MockSanitizedHTMLWrapper(
{
content,
className,
onClick,
}: { content: string; className?: string; onClick?: () => void },
ref: React.ForwardedRef<HTMLDivElement>,
) {
return (
<div
ref={ref}
data-testid="sanitized-html"
className={className}
onClick={onClick}
onKeyDown={onClick}
role="button"
tabIndex={0}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
});
});
// Mock ShadTooltip
jest.mock("@/components/common/shadTooltipComponent", () => ({
__esModule: true,
default: ({
children,
content,
}: {
children: React.ReactNode;
content: string;
}) => <div data-tooltip={content}>{children}</div>,
}));
// Mock BaseModal
jest.mock("@/modals/baseModal", () => {
const React = require("react");
interface ChildrenProps {
children: React.ReactNode;
}
interface HeaderProps extends ChildrenProps {
description?: string;
}
interface TriggerProps extends ChildrenProps {
disable?: boolean;
asChild?: boolean;
}
interface BaseModalProps extends ChildrenProps {
open?: boolean;
setOpen?: (open: boolean) => void;
size?: string;
}
interface ReactChild {
type?: {
displayName?: string;
};
}
const MockContent = ({ children }: ChildrenProps) => (
<div data-testid="modal-content">{children}</div>
);
const MockHeader = ({ children, description }: HeaderProps) => (
<div data-testid="modal-header" data-description={description}>
{children}
</div>
);
const MockTrigger = ({ children, disable }: TriggerProps) => (
<div data-testid="modal-trigger" data-disabled={disable}>
{children}
</div>
);
const MockFooter = ({ children }: ChildrenProps) => (
<div data-testid="modal-footer">{children}</div>
);
function MockBaseModal({ children, open, setOpen, size }: BaseModalProps) {
// Only render children when open
if (!open) {
// Still render the trigger so we can click it
const trigger = React.Children.toArray(children).find(
(child: ReactChild) => child?.type?.displayName === "Trigger",
);
return (
<div data-testid="base-modal-closed">
{trigger}
<button data-testid="mock-open-modal" onClick={() => setOpen?.(true)}>
Open Modal
</button>
</div>
);
}
return (
<div data-testid="base-modal" data-size={size}>
{children}
</div>
);
}
MockContent.displayName = "Content";
MockHeader.displayName = "Header";
MockTrigger.displayName = "Trigger";
MockFooter.displayName = "Footer";
MockBaseModal.Content = MockContent;
MockBaseModal.Header = MockHeader;
MockBaseModal.Trigger = MockTrigger;
MockBaseModal.Footer = MockFooter;
return { __esModule: true, default: MockBaseModal };
});
// Mock varHighlightHTML
jest.mock("@/modals/promptModal/utils/var-highlight-html", () => ({
__esModule: true,
default: ({
name,
addCurlyBraces,
}: {
name: string;
addCurlyBraces: boolean;
}) => `<span class="highlighted">${name}</span>`,
}));
// Mock reactflowUtils
jest.mock("@/utils/reactflowUtils", () => ({
handleKeyDown: jest.fn(),
}));
describe("MustachePromptModal", () => {
const defaultProps = {
field_name: "template",
value: "",
setValue: jest.fn(),
nodeClass: {
template: {
template: { value: "" },
},
},
setNodeClass: jest.fn(),
children: <button data-testid="trigger">Open</button>,
disabled: false,
id: "test-modal",
readonly: false,
};
beforeEach(() => {
jest.clearAllMocks();
mockMutate.mockClear();
mockSetSuccessData.mockClear();
mockSetErrorData.mockClear();
mockSetNoticeData.mockClear();
});
describe("rendering", () => {
it("should render the modal trigger", () => {
render(<MustachePromptModal {...defaultProps} />);
expect(screen.getByTestId("modal-trigger")).toBeInTheDocument();
});
it("should render modal content when open", () => {
render(<MustachePromptModal {...defaultProps} />);
// Click to open the modal
fireEvent.click(screen.getByTestId("mock-open-modal"));
expect(screen.getByTestId("modal-content")).toBeInTheDocument();
expect(screen.getByTestId("modal-header")).toBeInTheDocument();
expect(screen.getByTestId("modal-footer")).toBeInTheDocument();
});
it("should display Edit Prompt title", () => {
render(<MustachePromptModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
expect(screen.getByTestId("modal-title")).toHaveTextContent(
"Edit Prompt",
);
});
it("should render with initial value", () => {
render(<MustachePromptModal {...defaultProps} value="Hello {{name}}" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const textarea = screen.getByTestId("modal-test-modal");
expect(textarea).toHaveValue("Hello {{name}}");
});
});
describe("variable extraction", () => {
it("should extract mustache variables and display as badges", () => {
render(
<MustachePromptModal
{...defaultProps}
value="Hello {{name}}, welcome to {{place}}!"
/>,
);
fireEvent.click(screen.getByTestId("mock-open-modal"));
// Look for badge elements
expect(screen.getByText("name")).toBeInTheDocument();
expect(screen.getByText("place")).toBeInTheDocument();
});
it("should not extract invalid variable patterns", () => {
render(
<MustachePromptModal
{...defaultProps}
value="Invalid: {{123abc}} {{with space}}"
/>,
);
fireEvent.click(screen.getByTestId("mock-open-modal"));
// Invalid patterns should not be shown as badges
expect(screen.queryByText("123abc")).not.toBeInTheDocument();
expect(screen.queryByText("with space")).not.toBeInTheDocument();
});
it("should deduplicate variables", () => {
render(
<MustachePromptModal
{...defaultProps}
value="{{name}} and {{name}} and {{name}}"
/>,
);
fireEvent.click(screen.getByTestId("mock-open-modal"));
// Should only show one badge for "name"
const badges = screen.getAllByText("name");
expect(badges).toHaveLength(1);
});
});
describe("textarea editing", () => {
it("should update input value when typing", async () => {
render(<MustachePromptModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const textarea = screen.getByTestId("modal-test-modal");
// Use fireEvent.change for mustache syntax since userEvent.type treats { as special chars
fireEvent.change(textarea, { target: { value: "Hello {{world}}" } });
expect(textarea).toHaveValue("Hello {{world}}");
});
it("should update variables when input changes", async () => {
render(<MustachePromptModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const textarea = screen.getByTestId("modal-test-modal");
// Use fireEvent.change for mustache syntax since userEvent.type treats { as special chars
fireEvent.change(textarea, { target: { value: "{{new_var}}" } });
expect(screen.getByText("new_var")).toBeInTheDocument();
});
});
describe("validation and saving", () => {
it("should call validatePrompt when save button is clicked", async () => {
const user = userEvent.setup();
render(<MustachePromptModal {...defaultProps} value="Hello {{name}}" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const saveButton = screen.getByTestId("genericModalBtnSave");
await user.click(saveButton);
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
name: "template",
template: "Hello {{name}}",
mustache: true,
}),
expect.any(Object),
);
});
it("should call setValue on successful validation", async () => {
const mockSetValue = jest.fn();
const mockSetNodeClass = jest.fn();
mockMutate.mockImplementation((data, options) => {
options.onSuccess({
frontend_node: {
template: { template: { value: data.template } },
},
input_variables: ["name"],
});
});
const user = userEvent.setup();
render(
<MustachePromptModal
{...defaultProps}
value="Hello {{name}}"
setValue={mockSetValue}
setNodeClass={mockSetNodeClass}
/>,
);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const saveButton = screen.getByTestId("genericModalBtnSave");
await user.click(saveButton);
expect(mockSetValue).toHaveBeenCalledWith("Hello {{name}}");
expect(mockSetSuccessData).toHaveBeenCalled();
});
it("should show notice when no input variables are found", async () => {
mockMutate.mockImplementation((data, options) => {
options.onSuccess({
frontend_node: {
template: { template: { value: data.template } },
},
input_variables: [],
});
});
const user = userEvent.setup();
render(<MustachePromptModal {...defaultProps} value="Hello world" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const saveButton = screen.getByTestId("genericModalBtnSave");
await user.click(saveButton);
expect(mockSetNoticeData).toHaveBeenCalled();
});
it("should show error on validation failure", async () => {
mockMutate.mockImplementation((data, options) => {
options.onError({
response: {
data: {
detail: "Validation failed",
},
},
});
});
const user = userEvent.setup();
render(<MustachePromptModal {...defaultProps} value="Hello {{name}}" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const saveButton = screen.getByTestId("genericModalBtnSave");
await user.click(saveButton);
expect(mockSetErrorData).toHaveBeenCalledWith(
expect.objectContaining({
list: expect.arrayContaining(["Validation failed"]),
}),
);
});
it("should show bug alert when apiReturn is empty", async () => {
mockMutate.mockImplementation((data, options) => {
options.onSuccess(null);
});
const user = userEvent.setup();
render(<MustachePromptModal {...defaultProps} value="Hello {{name}}" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const saveButton = screen.getByTestId("genericModalBtnSave");
await user.click(saveButton);
expect(mockSetErrorData).toHaveBeenCalled();
});
});
describe("readonly mode", () => {
it("should disable save button when readonly", () => {
render(<MustachePromptModal {...defaultProps} readonly={true} />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const saveButton = screen.getByTestId("genericModalBtnSave");
expect(saveButton).toBeDisabled();
});
});
describe("edit/preview mode toggle", () => {
it("should start in edit mode", () => {
render(<MustachePromptModal {...defaultProps} value="test" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
// In edit mode, textarea should be visible
const textarea = screen.getByTestId("modal-test-modal");
expect(textarea).toBeInTheDocument();
});
it("should switch to preview mode on blur", async () => {
render(<MustachePromptModal {...defaultProps} value="Hello {{name}}" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const textarea = screen.getByTestId("modal-test-modal");
fireEvent.blur(textarea);
// After blur, should show preview (SanitizedHTMLWrapper)
await waitFor(() => {
expect(screen.getByTestId("sanitized-html")).toBeInTheDocument();
});
});
it("should switch back to edit mode when clicking preview", async () => {
render(<MustachePromptModal {...defaultProps} value="Hello {{name}}" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
// Blur to switch to preview
const textarea = screen.getByTestId("modal-test-modal");
fireEvent.blur(textarea);
// Wait for preview mode
await waitFor(() => {
expect(screen.getByTestId("sanitized-html")).toBeInTheDocument();
});
// Click on preview to switch back to edit mode
fireEvent.click(screen.getByTestId("sanitized-html"));
// Should be back in edit mode
await waitFor(() => {
expect(screen.getByTestId("modal-test-modal")).toBeInTheDocument();
});
});
});
describe("CSS class computation", () => {
it("should use code-nohighlight class for short variable names", () => {
render(<MustachePromptModal {...defaultProps} value="{{a}} {{b}}" />);
fireEvent.click(screen.getByTestId("mock-open-modal"));
// Blur to preview mode
const textarea = screen.getByTestId("modal-test-modal");
fireEvent.blur(textarea);
// The SanitizedHTMLWrapper should have code-nohighlight class
const preview = screen.getByTestId("sanitized-html");
expect(preview.className).toContain("code-nohighlight");
});
});
describe("badge truncation", () => {
it("should truncate long variable names in badges", () => {
const longVarName = "a".repeat(70); // Over 59 characters
render(
<MustachePromptModal {...defaultProps} value={`{{${longVarName}}}`} />,
);
fireEvent.click(screen.getByTestId("mock-open-modal"));
// Should show truncated name with ...
const badge = screen.getByText(/\.\.\.$/);
expect(badge).toBeInTheDocument();
});
});
describe("value synchronization", () => {
it("should sync inputValue when value prop changes", () => {
const { rerender } = render(
<MustachePromptModal {...defaultProps} value="initial" />,
);
fireEvent.click(screen.getByTestId("mock-open-modal"));
const textarea = screen.getByTestId("modal-test-modal");
expect(textarea).toHaveValue("initial");
// Update the value prop
rerender(<MustachePromptModal {...defaultProps} value="updated" />);
expect(textarea).toHaveValue("updated");
});
});
});

View File

@ -0,0 +1,319 @@
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { usePostValidatePrompt } from "@/controllers/API/queries/nodes/use-post-validate-prompt";
import IconComponent from "../../components/common/genericIconComponent";
import SanitizedHTMLWrapper from "../../components/common/sanitizedHTMLWrapper";
import ShadTooltip from "../../components/common/shadTooltipComponent";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Textarea } from "../../components/ui/textarea";
import {
BUG_ALERT,
PROMPT_ERROR_ALERT,
PROMPT_SUCCESS_ALERT,
TEMP_NOTICE_ALERT,
} from "../../constants/alerts_constants";
import {
EDIT_TEXT_PLACEHOLDER,
MAX_WORDS_HIGHLIGHT,
MUSTACHE_PROMPT_DIALOG_SUBTITLE,
} from "../../constants/constants";
import useAlertStore from "../../stores/alertStore";
import { PromptModalType } from "../../types/components";
import { handleKeyDown } from "../../utils/reactflowUtils";
import { classNames } from "../../utils/utils";
import BaseModal from "../baseModal";
import varHighlightHTML from "../promptModal/utils/var-highlight-html";
// Simple regex to extract mustache variables - only matches valid {{variable_name}} patterns
const SIMPLE_VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
// Type for non-standard caretPositionFromPoint API (not supported in Safari)
interface CaretPosition {
offset: number;
}
interface DocumentWithCaretPosition extends Document {
caretPositionFromPoint(x: number, y: number): CaretPosition | null;
}
export default function MustachePromptModal({
field_name = "",
value,
setValue,
nodeClass,
setNodeClass,
children,
disabled,
id = "",
readonly = false,
}: PromptModalType): JSX.Element {
const [modalOpen, setModalOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const [isEdit, setIsEdit] = useState(true);
const [wordsHighlight, setWordsHighlight] = useState<Set<string>>(new Set());
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const setErrorData = useAlertStore((state) => state.setErrorData);
const setNoticeData = useAlertStore((state) => state.setNoticeData);
const divRef = useRef(null);
const _divRefPrompt = useRef(null);
const { mutate: postValidatePrompt } = usePostValidatePrompt();
const [clickPosition, setClickPosition] = useState({ x: 0, y: 0 });
const [scrollPosition, setScrollPosition] = useState(0);
const previewRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
function checkVariables(valueToCheck: string): void {
// Extract only valid mustache variables {{variable_name}}
const matches: string[] = [];
const regex = new RegExp(SIMPLE_VARIABLE_PATTERN.source, "g");
let match: RegExpExecArray | null = regex.exec(valueToCheck);
while (match !== null) {
const varName = match[1];
if (!matches.includes(varName)) {
matches.push(varName);
}
match = regex.exec(valueToCheck);
}
setWordsHighlight(new Set(matches.map((v) => `{{${v}}}`)));
}
useEffect(() => {
if (inputValue && inputValue !== "") {
checkVariables(inputValue);
}
}, [inputValue]);
const coloredContent = (typeof inputValue === "string" ? inputValue : "")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (match) => {
return varHighlightHTML({ name: match, addCurlyBraces: false });
})
.replace(/\n/g, "<br />");
useEffect(() => {
if (typeof value === "string") setInputValue(value);
}, [value, modalOpen]);
function getClassByNumberLength(): string {
let sumOfCaracteres: number = 0;
wordsHighlight.forEach((element) => {
sumOfCaracteres = sumOfCaracteres + element.replace(/[{}]/g, "").length;
});
return sumOfCaracteres > MAX_WORDS_HIGHLIGHT
? "code-highlight"
: "code-nohighlight";
}
// Function need some review, working for now
function validatePrompt(closeModal: boolean): void {
//nodeClass is always null on tweaks
postValidatePrompt(
{
name: field_name,
template: inputValue,
frontend_node: nodeClass!,
mustache: true,
},
{
onSuccess: (apiReturn) => {
if (field_name === "") {
field_name = Array.isArray(
apiReturn?.frontend_node?.custom_fields?.[""],
)
? (apiReturn?.frontend_node?.custom_fields?.[""][0] ?? "")
: (apiReturn?.frontend_node?.custom_fields?.[""] ?? "");
}
if (apiReturn) {
const inputVariables = apiReturn.input_variables ?? [];
if (
JSON.stringify(apiReturn?.frontend_node) !== JSON.stringify({})
) {
setValue(inputValue);
apiReturn.frontend_node.template.template.value = inputValue;
if (setNodeClass) setNodeClass(apiReturn?.frontend_node);
setModalOpen(closeModal);
setIsEdit(false);
}
if (!inputVariables || inputVariables.length === 0) {
setNoticeData({
title: TEMP_NOTICE_ALERT,
});
} else {
setSuccessData({
title: PROMPT_SUCCESS_ALERT,
});
}
} else {
setIsEdit(true);
setErrorData({
title: BUG_ALERT,
});
}
},
onError: (error) => {
setIsEdit(true);
return setErrorData({
title: PROMPT_ERROR_ALERT,
list: [error.response.data.detail ?? ""],
});
},
},
);
}
const handlePreviewClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isEdit && !readonly) {
const clickX = e.clientX;
const clickY = e.clientY;
setClickPosition({ x: clickX, y: clickY });
setScrollPosition(e.currentTarget.scrollTop);
setIsEdit(true);
}
};
useEffect(() => {
if (isEdit && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.scrollTop = scrollPosition;
const textArea = textareaRef.current;
const { x, y } = clickPosition;
// Use caretPositionFromPoint to get the closest text position. Does not work on Safari.
if ("caretPositionFromPoint" in document) {
const docWithCaret = document as DocumentWithCaretPosition;
const range = docWithCaret.caretPositionFromPoint(x, y)?.offset ?? 0;
if (range) {
const position = range;
textArea.setSelectionRange(position, position);
}
}
} else if (!isEdit && previewRef.current) {
previewRef.current.scrollTop = scrollPosition;
}
}, [isEdit, clickPosition, scrollPosition]);
return (
<BaseModal
onChangeOpenModal={(open) => {}}
open={modalOpen}
setOpen={setModalOpen}
size="x-large"
>
<BaseModal.Trigger disable={disabled} asChild>
{children}
</BaseModal.Trigger>
<BaseModal.Header description={MUSTACHE_PROMPT_DIALOG_SUBTITLE}>
<div className="flex w-full items-start gap-3">
<div className="flex">
<IconComponent
name="TerminalSquare"
className="h-6 w-6 pr-1 text-primary"
aria-hidden="true"
/>
<span className="pl-2" data-testid="modal-title">
Edit Prompt
</span>
</div>
</div>
</BaseModal.Header>
<BaseModal.Content overflowHidden>
<div className={classNames("flex h-full w-full rounded-lg border")}>
{isEdit && !readonly ? (
<Textarea
id={"modal-" + id}
data-testid={"modal-" + id}
ref={textareaRef}
className="form-input h-full w-full resize-none rounded-lg border-0 custom-scroll focus-visible:ring-1"
value={inputValue}
onBlur={() => {
setScrollPosition(textareaRef.current?.scrollTop || 0);
setIsEdit(false);
}}
autoFocus
onChange={(event) => {
setInputValue(event.target.value);
checkVariables(event.target.value);
}}
placeholder={EDIT_TEXT_PLACEHOLDER}
onKeyDown={(e) => {
handleKeyDown(e, inputValue, "");
}}
/>
) : (
<SanitizedHTMLWrapper
ref={previewRef}
className={getClassByNumberLength() + " bg-muted"}
onClick={handlePreviewClick}
content={coloredContent}
suppressWarning={true}
/>
)}
</div>
</BaseModal.Content>
<BaseModal.Footer>
<div className="flex w-full shrink-0 items-end justify-between">
<div className="mb-auto flex-1">
<div className="mr-2">
<div
ref={divRef}
className="max-h-20 overflow-y-auto custom-scroll"
>
<div className="flex flex-wrap items-center gap-2">
<IconComponent
name="Braces"
className="flex h-4 w-4 text-primary"
/>
<span className="text-md font-semibold text-primary">
Prompt Variables:
</span>
{Array.from(wordsHighlight).map((word, index) => (
<ShadTooltip
key={index}
content={word.replace(/[{}]/g, "")}
asChild={false}
>
<Badge
key={index}
variant="gray"
size="md"
className="max-w-[40vw] cursor-default truncate p-1 text-sm"
>
<div className="relative bottom-[1px]">
<span id={"badge" + index.toString()}>
{word.replace(/[{}]/g, "").length > 59
? word.replace(/[{}]/g, "").slice(0, 56) + "..."
: word.replace(/[{}]/g, "")}
</span>
</div>
</Badge>
</ShadTooltip>
))}
</div>
</div>
<span className="mt-2 text-xs text-muted-foreground">
Prompt variables can be created with any chosen name inside
double curly brackets, e.g. {"{{variable_name}}"}
</span>
</div>
</div>
<Button
data-testid="genericModalBtnSave"
id="genericModalBtnSave"
disabled={readonly}
onClick={() => {
validatePrompt(false);
}}
type="submit"
>
Check & Save
</Button>
</div>
</BaseModal.Footer>
</BaseModal>
);
}

View File

@ -1,6 +1,11 @@
import type { IVarHighlightType } from "../../../types/components";
export default function varHighlightHTML({ name }: IVarHighlightType): string {
const html = `<span class="chat-message-highlight">{${name}}</span>`;
return html;
export default function varHighlightHTML({
name,
addCurlyBraces,
}: IVarHighlightType): string {
if (addCurlyBraces) {
return `<span class="font-semibold chat-message-highlight">{${name}}</span>`;
}
return `<span class="font-semibold chat-message-highlight">${name}</span>`;
}

View File

@ -296,6 +296,7 @@ export type TextHighlightType = {
export interface IVarHighlightType {
name: string;
addCurlyBraces?: boolean;
}
export type IconComponentProps = {

View File

@ -0,0 +1,214 @@
import {
extractMustacheVariables,
type MustacheValidationResult,
validateMustacheTemplate,
} from "../mustacheUtils";
describe("mustacheUtils", () => {
describe("validateMustacheTemplate", () => {
describe("valid templates", () => {
it("should return valid for empty string", () => {
const result = validateMustacheTemplate("");
expect(result).toEqual({ isValid: true, variables: [] });
});
it("should return valid for plain text without variables", () => {
const result = validateMustacheTemplate("Hello, this is plain text.");
expect(result).toEqual({ isValid: true, variables: [] });
});
it("should return valid for simple variable", () => {
const result = validateMustacheTemplate("Hello {{name}}!");
expect(result.isValid).toBe(true);
expect(result.variables).toEqual(["name"]);
});
it("should return valid for multiple different variables", () => {
const result = validateMustacheTemplate(
"Hello {{first_name}} {{last_name}}!",
);
expect(result.isValid).toBe(true);
expect(result.variables).toEqual(["first_name", "last_name"]);
});
it("should deduplicate repeated variables", () => {
const result = validateMustacheTemplate(
"{{name}} is {{name}} and {{name}}",
);
expect(result.isValid).toBe(true);
expect(result.variables).toEqual(["name"]);
});
it("should accept variables starting with underscore", () => {
const result = validateMustacheTemplate("Value: {{_private_var}}");
expect(result.isValid).toBe(true);
expect(result.variables).toEqual(["_private_var"]);
});
it("should accept variables with numbers", () => {
const result = validateMustacheTemplate("Value: {{var123}}");
expect(result.isValid).toBe(true);
expect(result.variables).toEqual(["var123"]);
});
it("should handle multiline templates", () => {
const template = `
Hello {{name}},
Your order {{order_id}} is ready.
Thanks!
`;
const result = validateMustacheTemplate(template);
expect(result.isValid).toBe(true);
expect(result.variables).toEqual(["name", "order_id"]);
});
});
describe("dangerous patterns", () => {
it("should reject triple braces (unescaped HTML)", () => {
const result = validateMustacheTemplate("{{{unsafe}}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
expect(result.variables).toEqual([]);
});
it("should reject conditional sections start", () => {
const result = validateMustacheTemplate(
"{{#section}}content{{/section}}",
);
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
});
it("should reject conditional sections end", () => {
const result = validateMustacheTemplate("{{/section}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
});
it("should reject inverted sections", () => {
const result = validateMustacheTemplate(
"{{^inverted}}no value{{/inverted}}",
);
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
});
it("should reject unescaped variables with &", () => {
const result = validateMustacheTemplate("{{&unescaped}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
});
it("should reject partials", () => {
const result = validateMustacheTemplate("{{>partial}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
});
it("should reject comments", () => {
const result = validateMustacheTemplate("{{! this is a comment }}");
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
});
it("should reject current context", () => {
const result = validateMustacheTemplate("{{.}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain(
"Complex mustache syntax is not allowed",
);
});
});
describe("unclosed tags", () => {
it("should reject unclosed opening braces", () => {
const result = validateMustacheTemplate("Hello {{name");
expect(result.isValid).toBe(false);
expect(result.error).toContain("matching closing braces");
});
it("should reject multiple unclosed braces", () => {
const result = validateMustacheTemplate("{{foo}} and {{bar");
expect(result.isValid).toBe(false);
expect(result.error).toContain("matching closing braces");
});
});
describe("invalid variable names", () => {
it("should reject variable names starting with number", () => {
const result = validateMustacheTemplate("{{123abc}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain("Invalid mustache variable");
});
it("should reject variables with spaces", () => {
const result = validateMustacheTemplate("{{my variable}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain("Invalid mustache variable");
});
it("should reject variables with special characters", () => {
const result = validateMustacheTemplate("{{my-variable}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain("Invalid mustache variable");
});
it("should reject empty braces", () => {
const result = validateMustacheTemplate("{{}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain("Invalid mustache variable");
});
it("should reject variables with dots (nested access)", () => {
const result = validateMustacheTemplate("{{user.name}}");
expect(result.isValid).toBe(false);
expect(result.error).toContain("Invalid mustache variable");
});
});
});
describe("extractMustacheVariables", () => {
it("should extract variables from valid template", () => {
const variables = extractMustacheVariables(
"Hello {{name}}, your {{item}} is ready.",
);
expect(variables).toEqual(["name", "item"]);
});
it("should return empty array for invalid template", () => {
const variables = extractMustacheVariables(
"{{#section}}content{{/section}}",
);
expect(variables).toEqual([]);
});
it("should return empty array for empty template", () => {
const variables = extractMustacheVariables("");
expect(variables).toEqual([]);
});
it("should return empty array for template without variables", () => {
const variables = extractMustacheVariables("Just plain text");
expect(variables).toEqual([]);
});
it("should deduplicate variables", () => {
const variables = extractMustacheVariables("{{x}} + {{x}} = {{result}}");
expect(variables).toEqual(["x", "result"]);
});
});
});

View File

@ -0,0 +1,108 @@
/**
* Security utilities for mustache template processing.
* Mirrors the backend validation in lfx/utils/mustache_security.py
*/
// Regex pattern for simple variables only - same as backend
const SIMPLE_VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
// Patterns for complex mustache syntax that we want to block
const DANGEROUS_PATTERNS = [
/\{\{\{/, // Triple braces (unescaped HTML in Mustache)
/\{\{#/, // Conditionals/sections start
/\{\{\//, // Conditionals/sections end
/\{\{\^/, // Inverted sections
/\{\{&/, // Unescaped variables
/\{\{>/, // Partials
/\{\{!/, // Comments
/\{\{\./, // Current context
];
export interface MustacheValidationResult {
isValid: boolean;
error?: string;
variables: string[];
}
/**
* Validate that a mustache template only contains simple variable substitutions.
* Returns validation result with extracted variables if valid.
*/
export function validateMustacheTemplate(
template: string,
): MustacheValidationResult {
if (!template) {
return { isValid: true, variables: [] };
}
// Check for dangerous patterns
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(template)) {
return {
isValid: false,
error:
"Complex mustache syntax is not allowed. Only simple variable substitution like {{variable}} is permitted.",
variables: [],
};
}
}
// Check for unclosed tags - {{ without a matching }}
// Find all {{ and check each one has a closing }}
let searchPos = 0;
while (true) {
const openPos = template.indexOf("{{", searchPos);
if (openPos === -1) break;
const afterOpen = template.slice(openPos + 2);
const closePos = afterOpen.indexOf("}}");
if (closePos === -1) {
// No }} found after this {{
return {
isValid: false,
error: `Invalid template syntax. Check that all {{variables}} have matching closing braces.`,
variables: [],
};
}
// Move past this {{ to find the next one
searchPos = openPos + 2 + closePos + 2;
}
// Find all {{ }} patterns and validate them
const allMustachePatterns = template.match(/\{\{[^}]*\}\}/g) || [];
const simpleVarPattern = /^\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}$/;
for (const pattern of allMustachePatterns) {
if (!simpleVarPattern.test(pattern)) {
return {
isValid: false,
error: `Invalid mustache variable: ${pattern}. Only simple variable names like {{variable}} are allowed.`,
variables: [],
};
}
}
// Extract valid variables
const variables: string[] = [];
const regex = new RegExp(SIMPLE_VARIABLE_PATTERN.source, "g");
let match: RegExpExecArray | null = regex.exec(template);
while (match !== null) {
if (!variables.includes(match[1])) {
variables.push(match[1]);
}
match = regex.exec(template);
}
return { isValid: true, variables };
}
/**
* Extract simple variable names from a mustache template.
* Only extracts valid {{variable}} patterns, ignoring complex syntax.
*/
export function extractMustacheVariables(template: string): string[] {
const result = validateMustacheTemplate(template);
return result.variables;
}

View File

@ -1,10 +1,11 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures";
import { adjustScreenView } from "../../utils/adjust-screen-view";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
// Helper function to verify prompt variables
async function verifyPromptVariables(
page: any,
page: Page,
template: string,
expectedVars: string[],
isFirstTime = true,
@ -356,3 +357,239 @@ test(
}
},
);
test(
"PromptTemplateComponent - Double Brackets Variable Extraction",
{ tag: ["@release", "@workspace"], timeout: 60000 },
async ({ page }) => {
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("prompt");
await page.waitForSelector(
'[data-testid="models_and_agentsPrompt Template"]',
{
timeout: 3000,
},
);
await page
.locator('//*[@id="models_and_agentsPrompt Template"]')
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
// Wait for the node to appear
await page.waitForSelector('[data-testid="div-generic-node"]', {
timeout: 5000,
});
// Click on the node to select it
await page.getByTestId("div-generic-node").click();
// Wait for and click the edit button
await page.waitForSelector('[data-testid="edit-button-modal"]', {
timeout: 5000,
});
await page.getByTestId("edit-button-modal").last().click();
// Wait for the modal to open and the toggle to be visible
await page.waitForSelector(
'[data-testid="toggle_bool_edit_use_double_brackets"]',
{
timeout: 5000,
},
);
// Enable the "Use Double Brackets" toggle in the modal
await page.getByTestId("toggle_bool_edit_use_double_brackets").click();
// Verify the toggle is now checked
expect(
await page
.getByTestId("toggle_bool_edit_use_double_brackets")
.isChecked(),
).toBeTruthy();
// Close the modal
await page.getByText("Close").last().click();
// Now test double bracket variable extraction - click the mustache prompt button
await page.waitForSelector(
'[data-testid="button_open_mustache_prompt_modal"]',
{
timeout: 5000,
},
);
await page.getByTestId("button_open_mustache_prompt_modal").click();
// Wait for the prompt modal textarea (mustache modal uses different testid pattern)
const mustacheTextareaId = "modal-mustachepromptarea_mustache_template";
await page.waitForSelector(`[data-testid="${mustacheTextareaId}"]`, {
timeout: 5000,
});
await page.getByTestId(mustacheTextareaId).fill("Hello {{name}}!");
let value = await page.getByTestId(mustacheTextareaId).inputValue();
expect(value).toBe("Hello {{name}}!");
// Verify the variable badge shows "name"
await page.waitForSelector('//*[@id="badge0"]', { timeout: 5000 });
const valueBadgeOne = await page.locator('//*[@id="badge0"]').innerText();
expect(valueBadgeOne).toBe("name");
await page.getByTestId("genericModalBtnSave").click();
// Wait for modal to close and variable input to appear
await page.waitForSelector('[data-testid="textarea_str_name"]', {
timeout: 5000,
});
// Verify the input field for the variable was created
await page.getByTestId("textarea_str_name").click();
await page.getByTestId("textarea_str_name").fill("World");
value = await page.getByTestId("textarea_str_name").inputValue();
expect(value).toBe("World");
},
);
test(
"PromptTemplateComponent - Double Brackets Multiple Variables",
{ tag: ["@release", "@workspace"], timeout: 60000 },
async ({ page }) => {
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("prompt");
await page.waitForSelector(
'[data-testid="models_and_agentsPrompt Template"]',
{
timeout: 3000,
},
);
await page
.locator('//*[@id="models_and_agentsPrompt Template"]')
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
// Wait for the node to appear
await page.waitForSelector('[data-testid="div-generic-node"]', {
timeout: 5000,
});
// Click on the node to select it
await page.getByTestId("div-generic-node").click();
// Wait for and click the edit button
await page.waitForSelector('[data-testid="edit-button-modal"]', {
timeout: 5000,
});
await page.getByTestId("edit-button-modal").last().click();
// Wait for the modal to open and the toggle to be visible
await page.waitForSelector(
'[data-testid="toggle_bool_edit_use_double_brackets"]',
{
timeout: 5000,
},
);
// Enable the "Use Double Brackets" toggle in the modal
await page.getByTestId("toggle_bool_edit_use_double_brackets").click();
// Verify the toggle is now checked
expect(
await page
.getByTestId("toggle_bool_edit_use_double_brackets")
.isChecked(),
).toBeTruthy();
// Close the modal
await page.getByText("Close").last().click();
// Test multiple double bracket variables - click the mustache prompt button
await page.waitForSelector(
'[data-testid="button_open_mustache_prompt_modal"]',
{
timeout: 5000,
},
);
await page.getByTestId("button_open_mustache_prompt_modal").click();
// Wait for the prompt modal textarea (mustache modal uses different testid pattern)
const mustacheTextareaId = "modal-mustachepromptarea_mustache_template";
await page.waitForSelector(`[data-testid="${mustacheTextareaId}"]`, {
timeout: 5000,
});
await page
.getByTestId(mustacheTextareaId)
.fill("{{greeting}} {{name}}! You are {{age}} years old.");
const value = await page.getByTestId(mustacheTextareaId).inputValue();
expect(value).toBe("{{greeting}} {{name}}! You are {{age}} years old.");
// Verify all variable badges
await page.waitForSelector('//*[@id="badge0"]', { timeout: 5000 });
const badgeGreeting = await page.locator('//*[@id="badge0"]').innerText();
expect(badgeGreeting).toBe("greeting");
const badgeName = await page.locator('//*[@id="badge1"]').innerText();
expect(badgeName).toBe("name");
const badgeAge = await page.locator('//*[@id="badge2"]').innerText();
expect(badgeAge).toBe("age");
// Verify no extra badges
const extraBadge = await page
.locator('//*[@id="badge3"]')
.isVisible()
.catch(() => false);
expect(extraBadge).toBeFalsy();
await page.getByTestId("genericModalBtnSave").click();
// Wait for modal to close and verify input fields for all variables were created
await page.waitForSelector('[data-testid="textarea_str_greeting"]', {
timeout: 5000,
});
await page.waitForSelector('[data-testid="textarea_str_name"]', {
timeout: 5000,
});
await page.waitForSelector('[data-testid="textarea_str_age"]', {
timeout: 5000,
});
// Fill in the variable inputs and verify they work
await page.getByTestId("textarea_str_greeting").fill("Hello");
expect(await page.getByTestId("textarea_str_greeting").inputValue()).toBe(
"Hello",
);
await page.getByTestId("textarea_str_name").fill("Alice");
expect(await page.getByTestId("textarea_str_name").inputValue()).toBe(
"Alice",
);
await page.getByTestId("textarea_str_age").fill("25");
expect(await page.getByTestId("textarea_str_age").inputValue()).toBe("25");
},
);

View File

@ -1,9 +1,14 @@
import { expect, type Page } from "@playwright/test";
import { type Page } from "@playwright/test";
export const addLegacyComponents = async (page: Page) => {
await page.getByTestId("sidebar-options-trigger").click();
await page.getByTestId("sidebar-legacy-switch").isVisible({ timeout: 5000 });
await page.getByTestId("sidebar-legacy-switch").click();
await expect(page.getByTestId("sidebar-legacy-switch")).toBeChecked();
await page.waitForSelector(
'[data-testid="sidebar-legacy-switch"][data-state="checked"]',
{
timeout: 5000,
},
);
await page.getByTestId("sidebar-options-trigger").click();
};

View File

@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { type Page } from "@playwright/test";
export const navigateSettingsPages = async (
page: Page,
@ -17,8 +17,5 @@ export const navigateSettingsPages = async (
timeout: 5000,
});
await page.waitForTimeout(500);
await expect(page.getByTestId("settings_menu_header").last()).toContainText(
settingsMenuName,
);
}
};

View File

@ -1,18 +1,22 @@
import { expect, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
export async function lockFlow(page: Page) {
await page.getByTestId("flow_name").click();
await page.getByTestId("lock-flow-switch").click();
await page.getByTestId("icon-Lock").isVisible({ timeout: 5000 });
const lockSwitch = page.getByTestId("lock-flow-switch");
await expect(lockSwitch).toBeVisible({ timeout: 1000 });
await expect(lockSwitch).toHaveAttribute("data-state", "checked");
await page.waitForSelector(
'[data-testid="lock-flow-switch"][data-state="checked"]',
{
timeout: 1000,
},
);
await page.waitForTimeout(500);
await page.getByTestId("save-flow-settings").isEnabled({ timeout: 3000 });
await page.getByTestId("save-flow-settings").click();
await expect(page.getByTestId("save-flow-settings")).toBeHidden({
await page.waitForSelector('[data-testid="save-flow-settings"]', {
state: "hidden",
timeout: 5000,
});
//ensure the UI is updated
@ -23,15 +27,19 @@ export async function unlockFlow(page: Page) {
await page.getByTestId("flow_name").click();
await page.getByTestId("lock-flow-switch").click();
await page.getByTestId("icon-Lock").isVisible({ timeout: 5000 });
const lockSwitch = page.getByTestId("lock-flow-switch");
await expect(lockSwitch).toBeVisible({ timeout: 1000 });
await expect(lockSwitch).toHaveAttribute("data-state", "unchecked");
await page.waitForSelector(
'[data-testid="lock-flow-switch"][data-state="unchecked"]',
{
timeout: 1000,
},
);
//ensure the UI is updated
await page.waitForTimeout(500);
await page.getByTestId("save-flow-settings").isEnabled({ timeout: 3000 });
await page.getByTestId("save-flow-settings").click();
await expect(page.getByTestId("save-flow-settings")).toBeHidden({
await page.waitForSelector('[data-testid="save-flow-settings"]', {
state: "hidden",
timeout: 5000,
});
}

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,7 @@ from typing import Any
from fastapi import HTTPException
from langchain_core.prompts import PromptTemplate
from langchain_core.prompts.string import mustache_template_vars
from lfx.inputs.inputs import DefaultPromptField
from lfx.interface.utils import extract_input_variables_from_prompt
@ -90,7 +91,7 @@ def _check_variable(var, invalid_chars, wrong_variables, empty_variables):
def _check_for_errors(input_variables, fixed_variables, wrong_variables, empty_variables) -> None:
if any(var for var in input_variables if var not in fixed_variables):
error_message = (
f"Error: Input variables contain invalid characters or formats. \n"
f"Input variables contain invalid characters or formats. \n"
f"Invalid variables: {', '.join(wrong_variables)}.\n"
f"Empty variables: {', '.join(empty_variables)}. \n"
f"Fixed variables: {', '.join(fixed_variables)}."
@ -122,8 +123,37 @@ def _check_input_variables(input_variables):
return fixed_variables
def validate_prompt(prompt_template: str, *, silent_errors: bool = False) -> list[str]:
input_variables = extract_input_variables_from_prompt(prompt_template)
def validate_prompt(prompt_template: str, *, silent_errors: bool = False, is_mustache: bool = False) -> list[str]:
if is_mustache:
# Extract only mustache variables
try:
input_variables = mustache_template_vars(prompt_template)
except Exception as exc:
# Mustache parser errors are often cryptic (e.g., "unclosed tag at line 1")
# Provide a more helpful error message
error_str = str(exc).lower()
if "unclosed" in error_str or "tag" in error_str:
msg = "Invalid template syntax. Check that all {{variables}} have matching opening and closing braces."
else:
msg = f"Invalid mustache template: {exc}"
raise ValueError(msg) from exc
# Also get f-string variables to filter them out
fstring_vars = extract_input_variables_from_prompt(prompt_template)
# Only keep variables that are actually in mustache syntax (not in f-string syntax)
# This handles cases where template has both {var} and {{var}}
input_variables = [v for v in input_variables if v not in fstring_vars or f"{{{{{v}}}}}" in prompt_template]
else:
# Extract f-string variables
input_variables = extract_input_variables_from_prompt(prompt_template)
# Also get mustache variables to filter them out
mustache_vars = mustache_template_vars(prompt_template)
# Only keep variables that are NOT in mustache syntax
# This handles cases where template has both {var} and {{var}}
input_variables = [v for v in input_variables if v not in mustache_vars]
# Check if there are invalid characters in the input_variables
input_variables = _check_input_variables(input_variables)
@ -199,11 +229,16 @@ def update_input_variables_field(input_variables, template) -> None:
def process_prompt_template(
template: str, name: str, custom_fields: dict[str, list[str]] | None, frontend_node_template: dict[str, Any]
template: str,
name: str,
custom_fields: dict[str, list[str]] | None,
frontend_node_template: dict[str, Any],
*,
is_mustache: bool = False,
):
"""Process and validate prompt template, update template and custom fields."""
# Validate the prompt template and extract input variables
input_variables = validate_prompt(template)
input_variables = validate_prompt(template, is_mustache=is_mustache)
# Initialize custom_fields if None
if custom_fields is None:

View File

@ -1,22 +1,36 @@
from typing import Any
from lfx.base.prompts.api_utils import process_prompt_template
from lfx.custom.custom_component.component import Component
from lfx.inputs.input_mixin import FieldTypes
from lfx.inputs.inputs import DefaultPromptField
from lfx.io import MessageTextInput, Output, PromptInput
from lfx.io import BoolInput, MessageTextInput, Output, PromptInput
from lfx.log.logger import logger
from lfx.schema.dotdict import dotdict
from lfx.schema.message import Message
from lfx.template.utils import update_template_values
from lfx.utils.mustache_security import validate_mustache_template
class PromptComponent(Component):
display_name: str = "Prompt Template"
description: str = "Create a prompt template with dynamic variables."
documentation: str = "https://docs.langflow.org/components-prompts"
icon = "braces"
icon = "prompts"
trace_type = "prompt"
name = "Prompt Template"
priority = 0 # Set priority to 0 to make it appear first
inputs = [
PromptInput(name="template", display_name="Template"),
BoolInput(
name="use_double_brackets",
display_name="Use Double Brackets",
value=False,
advanced=True,
info="Use {{variable}} syntax instead of {variable}.",
real_time_refresh=True,
),
MessageTextInput(
name="tool_placeholder",
display_name="Tool Placeholder",
@ -30,34 +44,107 @@ class PromptComponent(Component):
Output(display_name="Prompt", name="prompt", method="build_prompt"),
]
def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:
"""Update the template field type based on the selected mode."""
if field_name == "use_double_brackets":
# Change the template field type based on mode
is_mustache = field_value is True
if is_mustache:
build_config["template"]["type"] = FieldTypes.MUSTACHE_PROMPT.value
else:
build_config["template"]["type"] = FieldTypes.PROMPT.value
# Re-process the template to update variables when mode changes
template_value = build_config.get("template", {}).get("value", "")
if template_value:
# Ensure custom_fields is properly initialized
if "custom_fields" not in build_config:
build_config["custom_fields"] = {}
# Clean up fields from the OLD mode before processing with NEW mode
# This ensures we don't keep fields with wrong syntax even if validation fails
old_custom_fields = build_config["custom_fields"].get("template", [])
for old_field in list(old_custom_fields):
# Remove the field from custom_fields and template
if old_field in old_custom_fields:
old_custom_fields.remove(old_field)
build_config.pop(old_field, None)
# Try to process template with new mode to add new variables
# If validation fails, at least we cleaned up old fields
try:
# Validate mustache templates for security
if is_mustache:
validate_mustache_template(template_value)
# Re-process template with new mode to add new variables
_ = process_prompt_template(
template=template_value,
name="template",
custom_fields=build_config["custom_fields"],
frontend_node_template=build_config,
is_mustache=is_mustache,
)
except ValueError as e:
# If validation fails, we still updated the mode and cleaned old fields
# User will see error when they try to save
logger.debug(f"Template validation failed during mode switch: {e}")
return build_config
async def build_prompt(self) -> Message:
prompt = Message.from_template(**self._attributes)
use_double_brackets = self.use_double_brackets if hasattr(self, "use_double_brackets") else False
template_format = "mustache" if use_double_brackets else "f-string"
prompt = await Message.from_template_and_variables(template_format=template_format, **self._attributes)
self.status = prompt.text
return prompt
def _update_template(self, frontend_node: dict):
prompt_template = frontend_node["template"]["template"]["value"]
custom_fields = frontend_node["custom_fields"]
frontend_node_template = frontend_node["template"]
_ = process_prompt_template(
template=prompt_template,
name="template",
custom_fields=custom_fields,
frontend_node_template=frontend_node_template,
)
use_double_brackets = frontend_node["template"].get("use_double_brackets", {}).get("value", False)
is_mustache = use_double_brackets is True
try:
# Validate mustache templates for security
if is_mustache:
validate_mustache_template(prompt_template)
custom_fields = frontend_node["custom_fields"]
frontend_node_template = frontend_node["template"]
_ = process_prompt_template(
template=prompt_template,
name="template",
custom_fields=custom_fields,
frontend_node_template=frontend_node_template,
is_mustache=is_mustache,
)
except ValueError as e:
# If validation fails, don't add variables but allow component to be created
logger.debug(f"Template validation failed in _update_template: {e}")
return frontend_node
async def update_frontend_node(self, new_frontend_node: dict, current_frontend_node: dict):
"""This function is called after the code validation is done."""
frontend_node = await super().update_frontend_node(new_frontend_node, current_frontend_node)
template = frontend_node["template"]["template"]["value"]
# Kept it duplicated for backwards compatibility
_ = process_prompt_template(
template=template,
name="template",
custom_fields=frontend_node["custom_fields"],
frontend_node_template=frontend_node["template"],
)
use_double_brackets = frontend_node["template"].get("use_double_brackets", {}).get("value", False)
is_mustache = use_double_brackets is True
try:
# Validate mustache templates for security
if is_mustache:
validate_mustache_template(template)
# Kept it duplicated for backwards compatibility
_ = process_prompt_template(
template=template,
name="template",
custom_fields=frontend_node["custom_fields"],
frontend_node_template=frontend_node["template"],
is_mustache=is_mustache,
)
except ValueError as e:
# If validation fails, don't add variables but allow component to be updated
logger.debug(f"Template validation failed in update_frontend_node: {e}")
# Now that template is updated, we need to grab any values that were set in the current_frontend_node
# and update the frontend_node with those values
update_template_values(new_template=frontend_node, previous_template=current_frontend_node["template"])

View File

@ -28,6 +28,7 @@ class FieldTypes(str, Enum):
AUTH = "auth"
FILE = "file"
PROMPT = "prompt"
MUSTACHE_PROMPT = "mustache"
CODE = "code"
OTHER = "other"
TABLE = "table"

View File

@ -120,6 +120,10 @@ class PromptInput(BaseInputMixin, ListableInputMixin, InputTraceMixin, ToolModeM
field_type: SerializableFieldTypes = FieldTypes.PROMPT
class MustachePromptInput(PromptInput):
field_type: SerializableFieldTypes = FieldTypes.MUSTACHE_PROMPT
class CodeInput(BaseInputMixin, ListableInputMixin, InputTraceMixin, ToolModeMixin):
field_type: SerializableFieldTypes = FieldTypes.CODE
@ -807,6 +811,7 @@ InputTypes: TypeAlias = (
| NestedDictInput
| ToolsInput
| PromptInput
| MustachePromptInput
| CodeInput
| SecretStrInput
| StrInput

View File

@ -27,6 +27,7 @@ from lfx.schema.properties import Properties, Source
from lfx.schema.validators import timestamp_to_str, timestamp_to_str_validator
from lfx.utils.constants import MESSAGE_SENDER_AI, MESSAGE_SENDER_NAME_AI, MESSAGE_SENDER_NAME_USER, MESSAGE_SENDER_USER
from lfx.utils.image import create_image_content_dict
from lfx.utils.mustache_security import safe_mustache_render
if TYPE_CHECKING:
from lfx.schema.dataframe import DataFrame
@ -287,28 +288,35 @@ class Message(Data):
prompt_json = prompt.to_json()
return cls(prompt=prompt_json)
def format_text(self):
def format_text(self, template_format="f-string"):
if template_format == "mustache":
# Use our secure mustache renderer
variables_with_str_values = dict_values_to_string(self.variables)
formatted_prompt = safe_mustache_render(self.template, variables_with_str_values)
self.text = formatted_prompt
return formatted_prompt
# Use langchain's template for other formats
from langchain_core.prompts.prompt import PromptTemplate
prompt_template = PromptTemplate.from_template(self.template)
prompt_template = PromptTemplate.from_template(self.template, template_format=template_format)
variables_with_str_values = dict_values_to_string(self.variables)
formatted_prompt = prompt_template.format(**variables_with_str_values)
self.text = formatted_prompt
return formatted_prompt
@classmethod
async def from_template_and_variables(cls, template: str, **variables):
async def from_template_and_variables(cls, template: str, template_format: str = "f-string", **variables):
# This method has to be async for backwards compatibility with versions
# >1.0.15, <1.1
return cls.from_template(template, **variables)
return cls.from_template(template, template_format=template_format, **variables)
# Define a sync version for backwards compatibility with versions >1.0.15, <1.1
@classmethod
def from_template(cls, template: str, **variables):
def from_template(cls, template: str, template_format: str = "f-string", **variables):
from langchain_core.prompts.chat import ChatPromptTemplate
instance = cls(template=template, variables=variables)
text = instance.format_text()
text = instance.format_text(template_format=template_format)
message = HumanMessage(content=text)
contents = []
for value in variables.values():

View File

@ -71,6 +71,7 @@ DIRECT_TYPES = [
"float",
"Any",
"prompt",
"mustache",
"code",
"NestedDict",
"table",

View File

@ -0,0 +1,79 @@
"""Security utilities for mustache template processing."""
import re
from typing import Any
# Regex pattern for simple variables only - same as frontend
SIMPLE_VARIABLE_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}")
# Patterns for complex mustache syntax that we want to block
DANGEROUS_PATTERNS = [
re.compile(r"\{\{\{"), # Triple braces (unescaped HTML in Mustache)
re.compile(r"\{\{#"), # Conditionals/sections start
re.compile(r"\{\{/"), # Conditionals/sections end
re.compile(r"\{\{\^"), # Inverted sections
re.compile(r"\{\{&"), # Unescaped variables
re.compile(r"\{\{>"), # Partials
re.compile(r"\{\{!"), # Comments
re.compile(r"\{\{\."), # Current context
]
def validate_mustache_template(template: str) -> None:
"""Validate that a mustache template only contains simple variable substitutions.
Raises ValueError if complex mustache syntax is detected.
"""
if not template:
return
# Check for dangerous patterns
for pattern in DANGEROUS_PATTERNS:
if pattern.search(template):
msg = (
"Complex mustache syntax is not allowed. Only simple variable substitution "
"like {{variable}} is permitted."
)
raise ValueError(msg)
# Check that all {{ }} patterns are simple variables
all_mustache_patterns = re.findall(r"\{\{[^}]*\}\}", template)
for pattern in all_mustache_patterns:
if not SIMPLE_VARIABLE_PATTERN.match(pattern):
msg = f"Invalid mustache variable: {pattern}. Only simple variable names like {{{{variable}}}} are allowed."
raise ValueError(msg)
def safe_mustache_render(template: str, variables: dict[str, Any]) -> str:
"""Safely render a mustache template with only simple variable substitution.
This function performs a single-pass replacement of all {{variable}} patterns.
Variable values that themselves contain mustache-like patterns (e.g., "{{other}}")
will NOT be processed - they are treated as literal strings. This prevents
injection attacks where user-controlled values could introduce new template variables.
Args:
template: The mustache template string
variables: Dictionary of variables to substitute
Returns:
The rendered template
Raises:
ValueError: If template contains complex mustache syntax
"""
# Validate template first
validate_mustache_template(template)
# Simple replacement - find all simple variables and replace them
def replace_variable(match):
var_name = match.group(1)
# Get the variable value directly (no dot notation support)
value = variables.get(var_name, "")
# Convert to string
return str(value) if value is not None else ""
# Replace all simple variables
return SIMPLE_VARIABLE_PATTERN.sub(replace_variable, template)

View File

@ -0,0 +1,319 @@
"""Tests for PromptComponent with f-string and double brackets syntax."""
from lfx.components.models_and_agents.prompt import PromptComponent
class TestPromptComponent:
"""Test the PromptComponent."""
def test_update_template_single_variable(self):
"""Test template update with a single variable."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "Hello {name}!"},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
assert "name" in result["custom_fields"]["template"]
assert "name" in result["template"]
def test_update_template_multiple_variables(self):
"""Test template with multiple variables."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "{greeting} {name}!"},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
assert "greeting" in result["custom_fields"]["template"]
assert "name" in result["custom_fields"]["template"]
def test_update_template_duplicate_variables(self):
"""Test template with duplicate variables only creates one field."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "Hello {name}! How are you {name}?"},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
assert result["custom_fields"]["template"].count("name") == 1
assert "name" in result["template"]
def test_update_template_no_variables(self):
"""Test template with no variables."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "Hello World!"},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
assert len(result["custom_fields"].get("template", [])) == 0
def test_update_template_escaped_braces(self):
"""Test template with escaped braces doesn't create variables."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "Result: {{not_a_var}} but {real_var} works"},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
# Only {real_var} should be extracted
assert "real_var" in result["custom_fields"]["template"]
# {{not_a_var}} should NOT be extracted in f-string mode
assert "not_a_var" not in result["custom_fields"]["template"]
async def test_build_prompt_basic(self):
"""Test building a basic prompt."""
component = PromptComponent()
component._attributes = {
"template": "Hello {name}!",
"name": "World",
}
result = await component.build_prompt()
assert result.text == "Hello World!"
assert result.template == "Hello {name}!"
async def test_build_prompt_multiple_variables(self):
"""Test building prompt with multiple variables."""
component = PromptComponent()
component._attributes = {
"template": "{greeting} {name}! You are {age} years old.",
"greeting": "Hello",
"name": "Alice",
"age": "25",
}
result = await component.build_prompt()
assert result.text == "Hello Alice! You are 25 years old."
async def test_update_frontend_node(self):
"""Test update_frontend_node processes template correctly."""
component = PromptComponent()
new_node = {
"template": {
"template": {"value": "Hello {name}!"},
},
"custom_fields": {},
}
current_node = {
"template": {"template": {"value": ""}},
}
result = await component.update_frontend_node(new_node, current_node)
assert "name" in result["custom_fields"]["template"]
assert "name" in result["template"]
async def test_update_frontend_node_creates_variable_fields(self):
"""Test that update_frontend_node creates fields for template variables."""
component = PromptComponent()
new_node = {
"template": {
"template": {"value": "Hello {name} and {greeting}!"},
},
"custom_fields": {},
}
current_node = {
"template": {"template": {"value": ""}},
}
result = await component.update_frontend_node(new_node, current_node)
# Both variables should be in custom_fields
assert "name" in result["custom_fields"]["template"]
assert "greeting" in result["custom_fields"]["template"]
class TestPromptComponentDoubleBrackets:
"""Test the PromptComponent with double brackets (mustache) syntax."""
def test_update_template_double_brackets_single_variable(self):
"""Test template update with a single double-bracket variable."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "Hello {{name}}!"},
"use_double_brackets": {"value": True},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
assert "name" in result["custom_fields"]["template"]
assert "name" in result["template"]
def test_update_template_double_brackets_multiple_variables(self):
"""Test template with multiple double-bracket variables."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "{{greeting}} {{name}}!"},
"use_double_brackets": {"value": True},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
assert "greeting" in result["custom_fields"]["template"]
assert "name" in result["custom_fields"]["template"]
def test_update_template_double_brackets_no_variables(self):
"""Test double-bracket template with no variables."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "Hello World!"},
"use_double_brackets": {"value": True},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
assert len(result["custom_fields"].get("template", [])) == 0
def test_update_template_double_brackets_ignores_single_braces(self):
"""Test that double-bracket mode ignores single-brace variables."""
component = PromptComponent()
frontend_node = {
"template": {
"template": {"value": "Hello {single} and {{double}}!"},
"use_double_brackets": {"value": True},
},
"custom_fields": {},
}
result = component._update_template(frontend_node)
# Only {{double}} should be extracted in double-bracket mode
assert "double" in result["custom_fields"]["template"]
# {single} should NOT be extracted
assert "single" not in result["custom_fields"].get("template", [])
async def test_build_prompt_double_brackets_basic(self):
"""Test building a basic prompt with double brackets."""
component = PromptComponent()
component.use_double_brackets = True
component._attributes = {
"template": "Hello {{name}}!",
"name": "World",
"use_double_brackets": True,
}
result = await component.build_prompt()
assert result.text == "Hello World!"
async def test_build_prompt_double_brackets_multiple_variables(self):
"""Test building prompt with multiple double-bracket variables."""
component = PromptComponent()
component.use_double_brackets = True
component._attributes = {
"template": "{{greeting}} {{name}}! You are {{age}} years old.",
"greeting": "Hello",
"name": "Alice",
"age": "25",
"use_double_brackets": True,
}
result = await component.build_prompt()
assert result.text == "Hello Alice! You are 25 years old."
async def test_build_prompt_default_is_single_brackets(self):
"""Test that default mode uses single brackets (f-string)."""
component = PromptComponent()
# Don't set use_double_brackets - should default to False
component._attributes = {
"template": "Hello {name}!",
"name": "World",
}
result = await component.build_prompt()
assert result.text == "Hello World!"
async def test_update_frontend_node_double_brackets(self):
"""Test update_frontend_node processes double-bracket template correctly."""
component = PromptComponent()
new_node = {
"template": {
"template": {"value": "Hello {{name}}!"},
"use_double_brackets": {"value": True},
},
"custom_fields": {},
}
current_node = {
"template": {"template": {"value": ""}},
}
result = await component.update_frontend_node(new_node, current_node)
assert "name" in result["custom_fields"]["template"]
assert "name" in result["template"]
def test_update_build_config_switches_to_mustache(self):
"""Test that update_build_config switches field type when enabling double brackets."""
component = PromptComponent()
build_config = {
"template": {"type": "prompt", "value": "Hello {{name}}!"},
"custom_fields": {"template": []},
}
result = component.update_build_config(build_config, field_value=True, field_name="use_double_brackets")
assert result["template"]["type"] == "mustache"
def test_update_build_config_switches_to_fstring(self):
"""Test that update_build_config switches field type when disabling double brackets."""
component = PromptComponent()
build_config = {
"template": {"type": "MustachePrompt", "value": "Hello {name}!"},
"custom_fields": {"template": []},
}
result = component.update_build_config(build_config, field_value=False, field_name="use_double_brackets")
assert result["template"]["type"] == "prompt"

View File

@ -0,0 +1,172 @@
"""Tests for mustache template processing in the Message class.
Note: Our mustache implementation only supports simple variable substitution
for security reasons. Complex features like conditionals, loops, and sections
are not supported.
"""
import pytest
from lfx.schema.message import Message
from lfx.utils.mustache_security import validate_mustache_template
class TestMustacheTemplateProcessing:
"""Test mustache template processing in the Message class."""
def test_format_text_mustache_basic(self):
"""Test basic mustache template formatting."""
message = Message(template="Hello {{name}}!", variables={"name": "World"})
result = message.format_text(template_format="mustache")
assert result == "Hello World!"
assert message.text == "Hello World!"
def test_format_text_mustache_multiple_variables(self):
"""Test mustache template with multiple variables."""
message = Message(
template="Hello {{name}}! You are {{age}} years old.", variables={"name": "Alice", "age": "25"}
)
result = message.format_text(template_format="mustache")
assert result == "Hello Alice! You are 25 years old."
def test_format_text_mustache_missing_variable(self):
"""Test mustache template with missing variable."""
message = Message(template="Hello {{name}}! You are {{age}} years old.", variables={"name": "Bob"})
result = message.format_text(template_format="mustache")
# Missing variables should render as empty strings
assert result == "Hello Bob! You are years old."
def test_format_text_mustache_no_variables(self):
"""Test mustache template with no variables."""
message = Message(template="Hello World!", variables={})
result = message.format_text(template_format="mustache")
assert result == "Hello World!"
def test_format_text_mustache_empty_template(self):
"""Test mustache template with empty template."""
message = Message(template="", variables={"name": "Test"})
result = message.format_text(template_format="mustache")
assert result == ""
def test_format_text_mustache_with_numeric_values(self):
"""Test mustache template with numeric values."""
message = Message(
template="Price: ${{price}}, Quantity: {{quantity}}", variables={"price": 19.99, "quantity": 3}
)
result = message.format_text(template_format="mustache")
assert result == "Price: $19.99, Quantity: 3"
def test_format_text_mustache_with_newlines(self):
"""Test mustache template with newlines."""
message = Message(
template="Line 1: {{line1}}\nLine 2: {{line2}}", variables={"line1": "First", "line2": "Second"}
)
result = message.format_text(template_format="mustache")
assert result == "Line 1: First\nLine 2: Second"
def test_format_text_mustache_with_empty_string_variable(self):
"""Test mustache template with empty string variable."""
message = Message(template="Hello {{name}}!", variables={"name": ""})
result = message.format_text(template_format="mustache")
assert result == "Hello !"
def test_format_text_mustache_with_none_variable(self):
"""Test mustache template with None variable."""
message = Message(template="Hello {{name}}!", variables={"name": None})
result = message.format_text(template_format="mustache")
# None should render as empty string
assert result == "Hello !"
async def test_from_template_and_variables_mustache(self):
"""Test from_template_and_variables with mustache format."""
message = await Message.from_template_and_variables(
template="Hello {{name}}!", template_format="mustache", name="World"
)
assert isinstance(message, Message)
assert message.text == "Hello World!"
assert message.template == "Hello {{name}}!"
assert message.variables == {"name": "World"}
async def test_from_template_and_variables_mustache_no_variables(self):
"""Test from_template_and_variables with no variables."""
message = await Message.from_template_and_variables(template="Static message", template_format="mustache")
assert isinstance(message, Message)
assert message.text == "Static message"
assert message.variables == {}
def test_format_text_mustache_preserves_original_variables(self):
"""Test that format_text doesn't modify the original variables."""
original_variables = {"name": "Test", "age": 25}
message = Message(template="Hello {{name}}, age {{age}}!", variables=original_variables.copy())
result = message.format_text(template_format="mustache")
assert result == "Hello Test, age 25!"
assert message.variables == original_variables
def test_format_text_mustache_with_zero_values(self):
"""Test mustache template with zero values."""
message = Message(template="Count: {{count}}, Price: {{price}}", variables={"count": 0, "price": 0.0})
result = message.format_text(template_format="mustache")
assert result == "Count: 0, Price: 0.0"
def test_mustache_security_rejects_conditionals(self):
"""Test that conditional syntax is rejected for security."""
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{#show}}Hello{{/show}}")
def test_mustache_security_rejects_inverted_sections(self):
"""Test that inverted section syntax is rejected for security."""
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{^items}}No items{{/items}}")
def test_mustache_security_rejects_loops(self):
"""Test that loop syntax is rejected for security."""
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{#items}}{{.}}{{/items}}")
def test_mustache_security_rejects_unescaped_variables(self):
"""Test that unescaped variable syntax is rejected for security."""
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{&variable}}")
def test_mustache_security_rejects_partials(self):
"""Test that partial syntax is rejected for security."""
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{>partial}}")
def test_mustache_security_rejects_comments(self):
"""Test that comment syntax is rejected for security."""
with pytest.raises(ValueError, match="Complex mustache syntax is not allowed"):
validate_mustache_template("{{!comment}}")
def test_mustache_security_allows_simple_variables(self):
"""Test that simple variables are allowed."""
# Should not raise
validate_mustache_template("Hello {{name}}!")
validate_mustache_template("{{var1}} and {{var2}}")
def test_mustache_security_rejects_dot_notation(self):
"""Test that dot notation is NOT allowed."""
with pytest.raises(ValueError, match="Invalid mustache variable"):
validate_mustache_template("{{user.name}}")
with pytest.raises(ValueError, match="Invalid mustache variable"):
validate_mustache_template("{{company.ceo.name}}")
def test_format_text_defaults_to_f_string(self):
"""Test that format_text defaults to f-string format."""
message = Message(template="Hello {name}!", variables={"name": "World"})
result = message.format_text() # No template_format specified
assert result == "Hello World!"