diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 6ce72e80df..a02f8a4d49 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -548,7 +548,7 @@ class UpdateConfig(BaseSettings): class WorkflowVariableTruncationConfig(BaseSettings): WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE: PositiveInt = Field( - # 100KB + # 1000 KiB 1024_000, description="Maximum size for variable to trigger final truncation.", ) diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index 7822ed4268..c430fba0b9 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -49,62 +49,80 @@ class IndexingRunner: self.storage = storage self.model_manager = ModelManager() + def _handle_indexing_error(self, document_id: str, error: Exception) -> None: + """Handle indexing errors by updating document status.""" + logger.exception("consume document failed") + document = db.session.get(DatasetDocument, document_id) + if document: + document.indexing_status = "error" + error_message = getattr(error, "description", str(error)) + document.error = str(error_message) + document.stopped_at = naive_utc_now() + db.session.commit() + def run(self, dataset_documents: list[DatasetDocument]): """Run the indexing process.""" for dataset_document in dataset_documents: + document_id = dataset_document.id try: + # Re-query the document to ensure it's bound to the current session + requeried_document = db.session.get(DatasetDocument, document_id) + if not requeried_document: + logger.warning("Document not found, skipping document id: %s", document_id) + continue + # get dataset - dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first() + dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() if not dataset: raise ValueError("no dataset found") # get the process rule stmt = select(DatasetProcessRule).where( - DatasetProcessRule.id == dataset_document.dataset_process_rule_id + DatasetProcessRule.id == requeried_document.dataset_process_rule_id ) processing_rule = db.session.scalar(stmt) if not processing_rule: raise ValueError("no process rule found") - index_type = dataset_document.doc_form + index_type = requeried_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() # extract - text_docs = self._extract(index_processor, dataset_document, processing_rule.to_dict()) + text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict()) # transform documents = self._transform( - index_processor, dataset, text_docs, dataset_document.doc_language, processing_rule.to_dict() + index_processor, dataset, text_docs, requeried_document.doc_language, processing_rule.to_dict() ) # save segment - self._load_segments(dataset, dataset_document, documents) + self._load_segments(dataset, requeried_document, documents) # load self._load( index_processor=index_processor, dataset=dataset, - dataset_document=dataset_document, + dataset_document=requeried_document, documents=documents, ) except DocumentIsPausedError: - raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}") + raise DocumentIsPausedError(f"Document paused, document id: {document_id}") except ProviderTokenNotInitError as e: - dataset_document.indexing_status = "error" - dataset_document.error = str(e.description) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) except ObjectDeletedError: - logger.warning("Document deleted, document id: %s", dataset_document.id) + logger.warning("Document deleted, document id: %s", document_id) except Exception as e: - logger.exception("consume document failed") - dataset_document.indexing_status = "error" - dataset_document.error = str(e) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) def run_in_splitting_status(self, dataset_document: DatasetDocument): """Run the indexing process when the index_status is splitting.""" + document_id = dataset_document.id try: + # Re-query the document to ensure it's bound to the current session + requeried_document = db.session.get(DatasetDocument, document_id) + if not requeried_document: + logger.warning("Document not found: %s", document_id) + return + # get dataset - dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first() + dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() if not dataset: raise ValueError("no dataset found") @@ -112,57 +130,60 @@ class IndexingRunner: # get exist document_segment list and delete document_segments = ( db.session.query(DocumentSegment) - .filter_by(dataset_id=dataset.id, document_id=dataset_document.id) + .filter_by(dataset_id=dataset.id, document_id=requeried_document.id) .all() ) for document_segment in document_segments: db.session.delete(document_segment) - if dataset_document.doc_form == IndexType.PARENT_CHILD_INDEX: + if requeried_document.doc_form == IndexType.PARENT_CHILD_INDEX: # delete child chunks db.session.query(ChildChunk).where(ChildChunk.segment_id == document_segment.id).delete() db.session.commit() # get the process rule - stmt = select(DatasetProcessRule).where(DatasetProcessRule.id == dataset_document.dataset_process_rule_id) + stmt = select(DatasetProcessRule).where(DatasetProcessRule.id == requeried_document.dataset_process_rule_id) processing_rule = db.session.scalar(stmt) if not processing_rule: raise ValueError("no process rule found") - index_type = dataset_document.doc_form + index_type = requeried_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() # extract - text_docs = self._extract(index_processor, dataset_document, processing_rule.to_dict()) + text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict()) # transform documents = self._transform( - index_processor, dataset, text_docs, dataset_document.doc_language, processing_rule.to_dict() + index_processor, dataset, text_docs, requeried_document.doc_language, processing_rule.to_dict() ) # save segment - self._load_segments(dataset, dataset_document, documents) + self._load_segments(dataset, requeried_document, documents) # load self._load( - index_processor=index_processor, dataset=dataset, dataset_document=dataset_document, documents=documents + index_processor=index_processor, + dataset=dataset, + dataset_document=requeried_document, + documents=documents, ) except DocumentIsPausedError: - raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}") + raise DocumentIsPausedError(f"Document paused, document id: {document_id}") except ProviderTokenNotInitError as e: - dataset_document.indexing_status = "error" - dataset_document.error = str(e.description) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) except Exception as e: - logger.exception("consume document failed") - dataset_document.indexing_status = "error" - dataset_document.error = str(e) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) def run_in_indexing_status(self, dataset_document: DatasetDocument): """Run the indexing process when the index_status is indexing.""" + document_id = dataset_document.id try: + # Re-query the document to ensure it's bound to the current session + requeried_document = db.session.get(DatasetDocument, document_id) + if not requeried_document: + logger.warning("Document not found: %s", document_id) + return + # get dataset - dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first() + dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() if not dataset: raise ValueError("no dataset found") @@ -170,7 +191,7 @@ class IndexingRunner: # get exist document_segment list and delete document_segments = ( db.session.query(DocumentSegment) - .filter_by(dataset_id=dataset.id, document_id=dataset_document.id) + .filter_by(dataset_id=dataset.id, document_id=requeried_document.id) .all() ) @@ -188,7 +209,7 @@ class IndexingRunner: "dataset_id": document_segment.dataset_id, }, ) - if dataset_document.doc_form == IndexType.PARENT_CHILD_INDEX: + if requeried_document.doc_form == IndexType.PARENT_CHILD_INDEX: child_chunks = document_segment.get_child_chunks() if child_chunks: child_documents = [] @@ -206,24 +227,20 @@ class IndexingRunner: document.children = child_documents documents.append(document) # build index - index_type = dataset_document.doc_form + index_type = requeried_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() self._load( - index_processor=index_processor, dataset=dataset, dataset_document=dataset_document, documents=documents + index_processor=index_processor, + dataset=dataset, + dataset_document=requeried_document, + documents=documents, ) except DocumentIsPausedError: - raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}") + raise DocumentIsPausedError(f"Document paused, document id: {document_id}") except ProviderTokenNotInitError as e: - dataset_document.indexing_status = "error" - dataset_document.error = str(e.description) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) except Exception as e: - logger.exception("consume document failed") - dataset_document.indexing_status = "error" - dataset_document.error = str(e) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) def indexing_estimate( self, diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index e64ac25ab1..bd893b17f1 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -100,7 +100,7 @@ class LLMGenerator: return name @classmethod - def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str): + def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str) -> Sequence[str]: output_parser = SuggestedQuestionsAfterAnswerOutputParser() format_instructions = output_parser.get_format_instructions() @@ -119,6 +119,8 @@ class LLMGenerator: prompt_messages = [UserPromptMessage(content=prompt)] + questions: Sequence[str] = [] + try: response: LLMResult = model_instance.invoke_llm( prompt_messages=list(prompt_messages), diff --git a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py index e78859cc1a..eec771181f 100644 --- a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py +++ b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py @@ -1,17 +1,26 @@ import json +import logging import re +from collections.abc import Sequence from core.llm_generator.prompts import SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT +logger = logging.getLogger(__name__) + class SuggestedQuestionsAfterAnswerOutputParser: def get_format_instructions(self) -> str: return SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT - def parse(self, text: str): + def parse(self, text: str) -> Sequence[str]: action_match = re.search(r"\[.*?\]", text.strip(), re.DOTALL) + questions: list[str] = [] if action_match is not None: - json_obj = json.loads(action_match.group(0).strip()) - else: - json_obj = [] - return json_obj + try: + json_obj = json.loads(action_match.group(0).strip()) + except json.JSONDecodeError as exc: + logger.warning("Failed to decode suggested questions payload: %s", exc) + else: + if isinstance(json_obj, list): + questions = [question for question in json_obj if isinstance(question, str)] + return questions diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index e4637e6e95..1644f683bf 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -441,10 +441,14 @@ class LLMNode(Node): usage = LLMUsage.empty_usage() finish_reason = None full_text_buffer = io.StringIO() + collected_structured_output = None # Collect structured_output from streaming chunks # Consume the invoke result and handle generator exception try: for result in invoke_result: if isinstance(result, LLMResultChunkWithStructuredOutput): + # Collect structured_output from the chunk + if result.structured_output is not None: + collected_structured_output = dict(result.structured_output) yield result if isinstance(result, LLMResultChunk): contents = result.delta.message.content @@ -492,6 +496,8 @@ class LLMNode(Node): finish_reason=finish_reason, # Reasoning content for workflow variables and downstream nodes reasoning_content=reasoning_content, + # Pass structured output if collected from streaming chunks + structured_output=collected_structured_output, ) @staticmethod diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py index 2b65cc30b6..e250650fef 100644 --- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py +++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py @@ -747,7 +747,7 @@ class ParameterExtractorNode(Node): if model_mode == ModelMode.CHAT: system_prompt_messages = ChatModelMessage( role=PromptMessageRole.SYSTEM, - text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str).replace("{{instructions}}", instruction), + text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str, instructions=instruction), ) user_prompt_message = ChatModelMessage(role=PromptMessageRole.USER, text=input_text) return [system_prompt_messages, user_prompt_message] diff --git a/api/core/workflow/nodes/parameter_extractor/prompts.py b/api/core/workflow/nodes/parameter_extractor/prompts.py index b74be8f206..1b29be4418 100644 --- a/api/core/workflow/nodes/parameter_extractor/prompts.py +++ b/api/core/workflow/nodes/parameter_extractor/prompts.py @@ -135,7 +135,7 @@ Here are the chat histories between human and assistant, inside -{{instructions}} +{instructions} """ diff --git a/api/events/event_handlers/clean_when_dataset_deleted.py b/api/events/event_handlers/clean_when_dataset_deleted.py index 0f6aa0e778..1666e2e29f 100644 --- a/api/events/event_handlers/clean_when_dataset_deleted.py +++ b/api/events/event_handlers/clean_when_dataset_deleted.py @@ -6,8 +6,8 @@ from tasks.clean_dataset_task import clean_dataset_task @dataset_was_deleted.connect def handle(sender: Dataset, **kwargs): dataset = sender - assert dataset.doc_form - assert dataset.indexing_technique + if not dataset.doc_form or not dataset.indexing_technique: + return clean_dataset_task.delay( dataset.id, dataset.tenant_id, diff --git a/api/events/event_handlers/clean_when_document_deleted.py b/api/events/event_handlers/clean_when_document_deleted.py index bbc913b7cf..0add109b06 100644 --- a/api/events/event_handlers/clean_when_document_deleted.py +++ b/api/events/event_handlers/clean_when_document_deleted.py @@ -8,6 +8,6 @@ def handle(sender, **kwargs): dataset_id = kwargs.get("dataset_id") doc_form = kwargs.get("doc_form") file_id = kwargs.get("file_id") - assert dataset_id is not None - assert doc_form is not None + if not dataset_id or not doc_form: + return clean_document_task.delay(document_id, dataset_id, doc_form, file_id) diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 52fef4929f..82f0542b35 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -1,7 +1,12 @@ from configs import dify_config -from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN +from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT from dify_app import DifyApp +BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT) +SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization") +AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN) +FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN) + def init_app(app: DifyApp): # register blueprint routers @@ -17,7 +22,7 @@ def init_app(app: DifyApp): CORS( service_api_bp, - allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE], + allow_headers=list(SERVICE_API_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], ) app.register_blueprint(service_api_bp) @@ -26,7 +31,7 @@ def init_app(app: DifyApp): web_bp, resources={r"/*": {"origins": dify_config.WEB_API_CORS_ALLOW_ORIGINS}}, supports_credentials=True, - allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN], + allow_headers=list(AUTHENTICATED_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], expose_headers=["X-Version", "X-Env"], ) @@ -36,7 +41,7 @@ def init_app(app: DifyApp): console_app_bp, resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}}, supports_credentials=True, - allow_headers=["Content-Type", "Authorization", HEADER_NAME_CSRF_TOKEN], + allow_headers=list(AUTHENTICATED_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], expose_headers=["X-Version", "X-Env"], ) @@ -44,7 +49,7 @@ def init_app(app: DifyApp): CORS( files_bp, - allow_headers=["Content-Type", HEADER_NAME_CSRF_TOKEN], + allow_headers=list(FILES_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], ) app.register_blueprint(files_bp) diff --git a/api/migrations/versions/2025_10_14_1618-d98acf217d43_add_app_mode_for_messsage.py b/api/migrations/versions/2025_10_14_1618-d98acf217d43_add_app_mode_for_messsage.py index 7d6797fca0..910cf75838 100644 --- a/api/migrations/versions/2025_10_14_1618-d98acf217d43_add_app_mode_for_messsage.py +++ b/api/migrations/versions/2025_10_14_1618-d98acf217d43_add_app_mode_for_messsage.py @@ -22,55 +22,6 @@ def upgrade(): batch_op.add_column(sa.Column('app_mode', sa.String(length=255), nullable=True)) batch_op.create_index('message_app_mode_idx', ['app_mode'], unique=False) - conn = op.get_bind() - - # Strategy: Update in batches to minimize lock time - # For large tables (millions of rows), this prevents long-running transactions - batch_size = 10000 - - print("Starting backfill of app_mode from conversations...") - - # Use a more efficient UPDATE with JOIN - # This query updates messages.app_mode from conversations.mode - # Using string formatting for LIMIT since it's a constant - update_query = f""" - UPDATE messages m - SET app_mode = c.mode - FROM conversations c - WHERE m.conversation_id = c.id - AND m.app_mode IS NULL - AND m.id IN ( - SELECT id FROM messages - WHERE app_mode IS NULL - LIMIT {batch_size} - ) - """ - - # Execute batched updates - total_updated = 0 - iteration = 0 - while True: - iteration += 1 - result = conn.execute(sa.text(update_query)) - - # Check if result is None or has no rowcount - if result is None: - print("Warning: Query returned None, stopping backfill") - break - - rows_updated = result.rowcount if hasattr(result, 'rowcount') else 0 - total_updated += rows_updated - - if rows_updated == 0: - break - - print(f"Iteration {iteration}: Updated {rows_updated} messages (total: {total_updated})") - - # For very large tables, add a small delay to reduce load - # Uncomment if needed: import time; time.sleep(0.1) - - print(f"Backfill completed. Total messages updated: {total_updated}") - # ### end Alembic commands ### diff --git a/api/pyproject.toml b/api/pyproject.toml index b1af446fd6..a14c120f55 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -64,7 +64,7 @@ dependencies = [ "pycryptodome==3.19.1", "pydantic~=2.11.4", "pydantic-extra-types~=2.10.3", - "pydantic-settings~=2.9.1", + "pydantic-settings~=2.11.0", "pyjwt~=2.10.1", "pypdfium2==4.30.0", "python-docx~=1.1.0", diff --git a/api/services/message_service.py b/api/services/message_service.py index 9fdff18622..7ed56d80f2 100644 --- a/api/services/message_service.py +++ b/api/services/message_service.py @@ -288,9 +288,10 @@ class MessageService: ) with measure_time() as timer: - questions: list[str] = LLMGenerator.generate_suggested_questions_after_answer( + questions_sequence = LLMGenerator.generate_suggested_questions_after_answer( tenant_id=app_model.tenant_id, histories=histories ) + questions: list[str] = list(questions_sequence) # get tracing instance trace_manager = TraceQueueManager(app_id=app_model.id) diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index d02508e4f3..a8f37c31c8 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -79,7 +79,7 @@ class VariableTruncator: self, string_length_limit=5000, array_element_limit: int = 20, - max_size_bytes: int = 1024_000, # 100KB + max_size_bytes: int = 1024_000, # 1000 KiB ): if string_length_limit <= 3: raise ValueError("string_length_limit should be greater than 3.") diff --git a/api/tests/unit_tests/services/test_dataset_service_delete_dataset.py b/api/tests/unit_tests/services/test_dataset_service_delete_dataset.py new file mode 100644 index 0000000000..cc718c9997 --- /dev/null +++ b/api/tests/unit_tests/services/test_dataset_service_delete_dataset.py @@ -0,0 +1,216 @@ +from unittest.mock import Mock, patch + +import pytest + +from models.account import Account, TenantAccountRole +from models.dataset import Dataset +from services.dataset_service import DatasetService + + +class DatasetDeleteTestDataFactory: + """Factory class for creating test data and mock objects for dataset delete tests.""" + + @staticmethod + def create_dataset_mock( + dataset_id: str = "dataset-123", + tenant_id: str = "test-tenant-123", + created_by: str = "creator-456", + doc_form: str | None = None, + indexing_technique: str | None = "high_quality", + **kwargs, + ) -> Mock: + """Create a mock dataset with specified attributes.""" + dataset = Mock(spec=Dataset) + dataset.id = dataset_id + dataset.tenant_id = tenant_id + dataset.created_by = created_by + dataset.doc_form = doc_form + dataset.indexing_technique = indexing_technique + for key, value in kwargs.items(): + setattr(dataset, key, value) + return dataset + + @staticmethod + def create_user_mock( + user_id: str = "user-789", + tenant_id: str = "test-tenant-123", + role: TenantAccountRole = TenantAccountRole.ADMIN, + **kwargs, + ) -> Mock: + """Create a mock user with specified attributes.""" + user = Mock(spec=Account) + user.id = user_id + user.current_tenant_id = tenant_id + user.current_role = role + for key, value in kwargs.items(): + setattr(user, key, value) + return user + + +class TestDatasetServiceDeleteDataset: + """ + Comprehensive unit tests for DatasetService.delete_dataset method. + + This test suite covers all deletion scenarios including: + - Normal dataset deletion with documents + - Empty dataset deletion (no documents, doc_form is None) + - Dataset deletion with missing indexing_technique + - Permission checks + - Event handling + + This test suite provides regression protection for issue #27073. + """ + + @pytest.fixture + def mock_dataset_service_dependencies(self): + """Common mock setup for dataset service dependencies.""" + with ( + patch("services.dataset_service.DatasetService.get_dataset") as mock_get_dataset, + patch("services.dataset_service.DatasetService.check_dataset_permission") as mock_check_perm, + patch("extensions.ext_database.db.session") as mock_db, + patch("services.dataset_service.dataset_was_deleted") as mock_dataset_was_deleted, + ): + yield { + "get_dataset": mock_get_dataset, + "check_permission": mock_check_perm, + "db_session": mock_db, + "dataset_was_deleted": mock_dataset_was_deleted, + } + + def test_delete_dataset_with_documents_success(self, mock_dataset_service_dependencies): + """ + Test successful deletion of a dataset with documents. + + This test verifies: + - Dataset is retrieved correctly + - Permission check is performed + - dataset_was_deleted event is sent + - Dataset is deleted from database + - Method returns True + """ + # Arrange + dataset = DatasetDeleteTestDataFactory.create_dataset_mock( + doc_form="text_model", indexing_technique="high_quality" + ) + user = DatasetDeleteTestDataFactory.create_user_mock() + + mock_dataset_service_dependencies["get_dataset"].return_value = dataset + + # Act + result = DatasetService.delete_dataset(dataset.id, user) + + # Assert + assert result is True + mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id) + mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user) + mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].commit.assert_called_once() + + def test_delete_empty_dataset_success(self, mock_dataset_service_dependencies): + """ + Test successful deletion of an empty dataset (no documents, doc_form is None). + + This test verifies that: + - Empty datasets can be deleted without errors + - dataset_was_deleted event is sent (event handler will skip cleanup if doc_form is None) + - Dataset is deleted from database + - Method returns True + + This is the primary test for issue #27073 where deleting an empty dataset + caused internal server error due to assertion failure in event handlers. + """ + # Arrange + dataset = DatasetDeleteTestDataFactory.create_dataset_mock(doc_form=None, indexing_technique=None) + user = DatasetDeleteTestDataFactory.create_user_mock() + + mock_dataset_service_dependencies["get_dataset"].return_value = dataset + + # Act + result = DatasetService.delete_dataset(dataset.id, user) + + # Assert - Verify complete deletion flow + assert result is True + mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id) + mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user) + mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].commit.assert_called_once() + + def test_delete_dataset_with_partial_none_values(self, mock_dataset_service_dependencies): + """ + Test deletion of dataset with partial None values. + + This test verifies that datasets with partial None values (e.g., doc_form exists + but indexing_technique is None) can be deleted successfully. The event handler + will skip cleanup if any required field is None. + + Improvement based on Gemini Code Assist suggestion: Added comprehensive assertions + to verify all core deletion operations are performed, not just event sending. + """ + # Arrange + dataset = DatasetDeleteTestDataFactory.create_dataset_mock(doc_form="text_model", indexing_technique=None) + user = DatasetDeleteTestDataFactory.create_user_mock() + + mock_dataset_service_dependencies["get_dataset"].return_value = dataset + + # Act + result = DatasetService.delete_dataset(dataset.id, user) + + # Assert - Verify complete deletion flow (Gemini suggestion implemented) + assert result is True + mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id) + mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user) + mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].commit.assert_called_once() + + def test_delete_dataset_with_doc_form_none_indexing_technique_exists(self, mock_dataset_service_dependencies): + """ + Test deletion of dataset where doc_form is None but indexing_technique exists. + + This edge case can occur in certain dataset configurations and should be handled + gracefully by the event handler's conditional check. + """ + # Arrange + dataset = DatasetDeleteTestDataFactory.create_dataset_mock(doc_form=None, indexing_technique="high_quality") + user = DatasetDeleteTestDataFactory.create_user_mock() + + mock_dataset_service_dependencies["get_dataset"].return_value = dataset + + # Act + result = DatasetService.delete_dataset(dataset.id, user) + + # Assert - Verify complete deletion flow + assert result is True + mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id) + mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user) + mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset) + mock_dataset_service_dependencies["db_session"].commit.assert_called_once() + + def test_delete_dataset_not_found(self, mock_dataset_service_dependencies): + """ + Test deletion attempt when dataset doesn't exist. + + This test verifies that: + - Method returns False when dataset is not found + - No deletion operations are performed + - No events are sent + """ + # Arrange + dataset_id = "non-existent-dataset" + user = DatasetDeleteTestDataFactory.create_user_mock() + + mock_dataset_service_dependencies["get_dataset"].return_value = None + + # Act + result = DatasetService.delete_dataset(dataset_id, user) + + # Assert + assert result is False + mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset_id) + mock_dataset_service_dependencies["check_permission"].assert_not_called() + mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_not_called() + mock_dataset_service_dependencies["db_session"].delete.assert_not_called() + mock_dataset_service_dependencies["db_session"].commit.assert_not_called() diff --git a/api/uv.lock b/api/uv.lock index f2c59cf189..3558f81c59 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1557,7 +1557,7 @@ requires-dist = [ { name = "pycryptodome", specifier = "==3.19.1" }, { name = "pydantic", specifier = "~=2.11.4" }, { name = "pydantic-extra-types", specifier = "~=2.10.3" }, - { name = "pydantic-settings", specifier = "~=2.9.1" }, + { name = "pydantic-settings", specifier = "~=2.11.0" }, { name = "pyjwt", specifier = "~=2.10.1" }, { name = "pypdfium2", specifier = "==4.30.0" }, { name = "python-docx", specifier = "~=1.1.0" }, @@ -4779,16 +4779,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.9.1" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, ] [[package]] diff --git a/web/.storybook/__mocks__/context-block.tsx b/web/.storybook/__mocks__/context-block.tsx new file mode 100644 index 0000000000..8a9d8625cc --- /dev/null +++ b/web/.storybook/__mocks__/context-block.tsx @@ -0,0 +1,4 @@ +// Mock for context-block plugin to avoid circular dependency in Storybook +export const ContextBlockNode = null +export const ContextBlockReplacementBlock = null +export default null diff --git a/web/.storybook/__mocks__/history-block.tsx b/web/.storybook/__mocks__/history-block.tsx new file mode 100644 index 0000000000..e3c3965d13 --- /dev/null +++ b/web/.storybook/__mocks__/history-block.tsx @@ -0,0 +1,4 @@ +// Mock for history-block plugin to avoid circular dependency in Storybook +export const HistoryBlockNode = null +export const HistoryBlockReplacementBlock = null +export default null diff --git a/web/.storybook/__mocks__/query-block.tsx b/web/.storybook/__mocks__/query-block.tsx new file mode 100644 index 0000000000..d82f51363a --- /dev/null +++ b/web/.storybook/__mocks__/query-block.tsx @@ -0,0 +1,4 @@ +// Mock for query-block plugin to avoid circular dependency in Storybook +export const QueryBlockNode = null +export const QueryBlockReplacementBlock = null +export default null diff --git a/web/.storybook/main.ts b/web/.storybook/main.ts index fecf774e98..e656115ceb 100644 --- a/web/.storybook/main.ts +++ b/web/.storybook/main.ts @@ -1,19 +1,46 @@ import type { StorybookConfig } from '@storybook/nextjs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) const config: StorybookConfig = { - // stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'], addons: [ '@storybook/addon-onboarding', '@storybook/addon-links', - '@storybook/addon-essentials', + '@storybook/addon-docs', '@chromatic-com/storybook', - '@storybook/addon-interactions', ], framework: { name: '@storybook/nextjs', - options: {}, + options: { + builder: { + useSWC: true, + lazyCompilation: false, + }, + nextConfigPath: undefined, + }, }, staticDirs: ['../public'], + core: { + disableWhatsNewNotifications: true, + }, + docs: { + defaultName: 'Documentation', + }, + webpackFinal: async (config) => { + // Add alias to mock problematic modules with circular dependencies + config.resolve = config.resolve || {} + config.resolve.alias = { + ...config.resolve.alias, + // Mock the plugin index files to avoid circular dependencies + [path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(__dirname, '__mocks__/context-block.tsx'), + [path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(__dirname, '__mocks__/history-block.tsx'), + [path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(__dirname, '__mocks__/query-block.tsx'), + } + return config + }, } export default config diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index 55328602f9..1f5726de34 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -1,12 +1,21 @@ -import React from 'react' import type { Preview } from '@storybook/react' import { withThemeByDataAttribute } from '@storybook/addon-themes' -import I18nServer from '../app/components/i18n-server' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import I18N from '../app/components/i18n' +import { ToastProvider } from '../app/components/base/toast' import '../app/styles/globals.css' import '../app/styles/markdown.scss' import './storybook.css' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}) + export const decorators = [ withThemeByDataAttribute({ themes: { @@ -17,9 +26,15 @@ export const decorators = [ attributeName: 'data-theme', }), (Story) => { - return - - + return ( + + + + + + + + ) }, ] @@ -31,7 +46,11 @@ const preview: Preview = { date: /Date$/i, }, }, + docs: { + toc: true, + }, }, + tags: ['autodocs'], } export default preview diff --git a/web/__tests__/navigation-utils.test.ts b/web/__tests__/navigation-utils.test.ts index 9a388505d6..fa4986e63d 100644 --- a/web/__tests__/navigation-utils.test.ts +++ b/web/__tests__/navigation-utils.test.ts @@ -160,8 +160,7 @@ describe('Navigation Utilities', () => { page: 1, limit: '', keyword: 'test', - empty: null, - undefined, + filter: '', }) expect(path).toBe('/datasets/123/documents?page=1&keyword=test') diff --git a/web/__tests__/real-browser-flicker.test.tsx b/web/__tests__/real-browser-flicker.test.tsx index f71e8de515..0a0ea0c062 100644 --- a/web/__tests__/real-browser-flicker.test.tsx +++ b/web/__tests__/real-browser-flicker.test.tsx @@ -39,28 +39,38 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query) const matches = isDarkQuery ? systemPrefersDark : false + const handleAddListener = (listener: (event: MediaQueryListEvent) => void) => { + listeners.add(listener) + } + + const handleRemoveListener = (listener: (event: MediaQueryListEvent) => void) => { + listeners.delete(listener) + } + + const handleAddEventListener = (_event: string, listener: EventListener) => { + if (typeof listener === 'function') + listeners.add(listener as (event: MediaQueryListEvent) => void) + } + + const handleRemoveEventListener = (_event: string, listener: EventListener) => { + if (typeof listener === 'function') + listeners.delete(listener as (event: MediaQueryListEvent) => void) + } + + const handleDispatchEvent = (event: Event) => { + listeners.forEach(listener => listener(event as MediaQueryListEvent)) + return true + } + const mediaQueryList: MediaQueryList = { matches, media: query, onchange: null, - addListener: (listener: MediaQueryListListener) => { - listeners.add(listener) - }, - removeListener: (listener: MediaQueryListListener) => { - listeners.delete(listener) - }, - addEventListener: (_event, listener: EventListener) => { - if (typeof listener === 'function') - listeners.add(listener as MediaQueryListListener) - }, - removeEventListener: (_event, listener: EventListener) => { - if (typeof listener === 'function') - listeners.delete(listener as MediaQueryListListener) - }, - dispatchEvent: (event: Event) => { - listeners.forEach(listener => listener(event as MediaQueryListEvent)) - return true - }, + addListener: handleAddListener, + removeListener: handleRemoveListener, + addEventListener: handleAddEventListener, + removeEventListener: handleRemoveEventListener, + dispatchEvent: handleDispatchEvent, } return mediaQueryList @@ -69,6 +79,121 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia) } +// Helper function to create timing page component +const createTimingPageComponent = ( + timingData: Array<{ phase: string; timestamp: number; styles: { backgroundColor: string; color: string } }>, +) => { + const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => { + timingData.push({ + phase, + timestamp: performance.now(), + styles, + }) + } + + const TimingPageComponent = () => { + const [mounted, setMounted] = useState(false) + const { theme } = useTheme() + const isDark = mounted ? theme === 'dark' : false + + const currentStyles = { + backgroundColor: isDark ? '#1f2937' : '#ffffff', + color: isDark ? '#ffffff' : '#000000', + } + + recordTiming(mounted ? 'CSR' : 'Initial', currentStyles) + + useEffect(() => { + setMounted(true) + }, []) + + return ( +
+
+ Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'} +
+
+ ) + } + + return TimingPageComponent +} + +// Helper function to create CSS test component +const createCSSTestComponent = ( + cssStates: Array<{ className: string; timestamp: number }>, +) => { + const recordCSSState = (className: string) => { + cssStates.push({ + className, + timestamp: performance.now(), + }) + } + + const CSSTestComponent = () => { + const [mounted, setMounted] = useState(false) + const { theme } = useTheme() + const isDark = mounted ? theme === 'dark' : false + + const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}` + + recordCSSState(className) + + useEffect(() => { + setMounted(true) + }, []) + + return ( +
+
Classes: {className}
+
+ ) + } + + return CSSTestComponent +} + +// Helper function to create performance test component +const createPerformanceTestComponent = ( + performanceMarks: Array<{ event: string; timestamp: number }>, +) => { + const recordPerformanceMark = (event: string) => { + performanceMarks.push({ event, timestamp: performance.now() }) + } + + const PerformanceTestComponent = () => { + const [mounted, setMounted] = useState(false) + const { theme } = useTheme() + + recordPerformanceMark('component-render') + + useEffect(() => { + recordPerformanceMark('mount-start') + setMounted(true) + recordPerformanceMark('mount-complete') + }, []) + + useEffect(() => { + if (theme) + recordPerformanceMark('theme-available') + }, [theme]) + + return ( +
+ Mounted: {mounted.toString()} | Theme: {theme || 'loading'} +
+ ) + } + + return PerformanceTestComponent +} + // Simulate real page component based on Dify's actual theme usage const PageComponent = () => { const [mounted, setMounted] = useState(false) @@ -227,39 +352,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { setupMockEnvironment('dark') const timingData: Array<{ phase: string; timestamp: number; styles: any }> = [] - - const TimingPageComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - const isDark = mounted ? theme === 'dark' : false - - // Record timing and styles for each render phase - const currentStyles = { - backgroundColor: isDark ? '#1f2937' : '#ffffff', - color: isDark ? '#ffffff' : '#000000', - } - - timingData.push({ - phase: mounted ? 'CSR' : 'Initial', - timestamp: performance.now(), - styles: currentStyles, - }) - - useEffect(() => { - setMounted(true) - }, []) - - return ( -
-
- Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'} -
-
- ) - } + const TimingPageComponent = createTimingPageComponent(timingData) render( @@ -295,33 +388,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { setupMockEnvironment('dark') const cssStates: Array<{ className: string; timestamp: number }> = [] - - const CSSTestComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - const isDark = mounted ? theme === 'dark' : false - - // Simulate Tailwind CSS class application - const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}` - - cssStates.push({ - className, - timestamp: performance.now(), - }) - - useEffect(() => { - setMounted(true) - }, []) - - return ( -
-
Classes: {className}
-
- ) - } + const CSSTestComponent = createCSSTestComponent(cssStates) render( @@ -413,34 +480,12 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { test('verifies ThemeProvider position fix reduces initialization delay', async () => { const performanceMarks: Array<{ event: string; timestamp: number }> = [] - const PerformanceTestComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - - performanceMarks.push({ event: 'component-render', timestamp: performance.now() }) - - useEffect(() => { - performanceMarks.push({ event: 'mount-start', timestamp: performance.now() }) - setMounted(true) - performanceMarks.push({ event: 'mount-complete', timestamp: performance.now() }) - }, []) - - useEffect(() => { - if (theme) - performanceMarks.push({ event: 'theme-available', timestamp: performance.now() }) - }, [theme]) - - return ( -
- Mounted: {mounted.toString()} | Theme: {theme || 'loading'} -
- ) - } - setupMockEnvironment('dark') expect(window.localStorage.getItem('theme')).toBe('dark') + const PerformanceTestComponent = createPerformanceTestComponent(performanceMarks) + render( diff --git a/web/__tests__/unified-tags-logic.test.ts b/web/__tests__/unified-tags-logic.test.ts index c920e28e0a..ec73a6a268 100644 --- a/web/__tests__/unified-tags-logic.test.ts +++ b/web/__tests__/unified-tags-logic.test.ts @@ -70,14 +70,18 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { }) describe('Fallback Logic (from layout-main.tsx)', () => { + type Tag = { id: string; name: string } + type AppDetail = { tags: Tag[] } + type FallbackResult = { tags?: Tag[] } | null + // no-op it('should trigger fallback when tags are missing or empty', () => { - const appDetailWithoutTags = { tags: [] } - const appDetailWithTags = { tags: [{ id: 'tag1' }] } - const appDetailWithUndefinedTags = { tags: undefined as any } + const appDetailWithoutTags: AppDetail = { tags: [] } + const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] } + const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined } // This simulates the condition in layout-main.tsx - const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0 - const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0 + const shouldFallback1 = appDetailWithoutTags.tags.length === 0 + const shouldFallback2 = appDetailWithTags.tags.length === 0 const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0 expect(shouldFallback1).toBe(true) // Empty array should trigger fallback @@ -86,24 +90,26 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { }) it('should preserve tags when fallback succeeds', () => { - const originalAppDetail = { tags: [] as any[] } - const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] } + const originalAppDetail: AppDetail = { tags: [] } + const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] } // This simulates the successful fallback in layout-main.tsx - if (fallbackResult?.tags) - originalAppDetail.tags = fallbackResult.tags + const tags = fallbackResult.tags + if (tags) + originalAppDetail.tags = tags expect(originalAppDetail.tags).toEqual(fallbackResult.tags) expect(originalAppDetail.tags.length).toBe(1) }) it('should continue with empty tags when fallback fails', () => { - const originalAppDetail: { tags: any[] } = { tags: [] } - const fallbackResult: { tags?: any[] } | null = null + const originalAppDetail: AppDetail = { tags: [] } + const fallbackResult = null as FallbackResult // This simulates fallback failure in layout-main.tsx - if (fallbackResult?.tags) - originalAppDetail.tags = fallbackResult.tags + const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined + if (tags) + originalAppDetail.tags = tags expect(originalAppDetail.tags).toEqual([]) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index e4c3f60c12..0ad02ad7f3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -73,7 +73,7 @@ const ConfigPopup: FC = ({ } }, [onChooseProvider]) - const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => { + const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig) => { onConfigUpdated(currentProvider!, payload) hideConfigModal() }, [currentProvider, hideConfigModal, onConfigUpdated]) diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index dadd112135..409c390f4b 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import type { AppPublisherProps } from '@/app/components/app/app-publisher' import Confirm from '@/app/components/base/confirm' import AppPublisher from '@/app/components/app/app-publisher' diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index e2d37bb9de..70e0334e98 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -5,7 +5,7 @@ import copy from 'copy-to-clipboard' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { useBoolean } from 'ahooks' -import produce from 'immer' +import { produce } from 'immer' import { RiDeleteBinLine, RiErrorWarningFill, diff --git a/web/app/components/app/configuration/config-prompt/index.tsx b/web/app/components/app/configuration/config-prompt/index.tsx index 1caca47bcb..ec34588e41 100644 --- a/web/app/components/app/configuration/config-prompt/index.tsx +++ b/web/app/components/app/configuration/config-prompt/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React from 'react' import { useContext } from 'use-context-selector' -import produce from 'immer' +import { produce } from 'immer' import { RiAddLine, } from '@remixicon/react' diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index a7bdc550d1..169e8a14a2 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' -import produce from 'immer' +import { produce } from 'immer' import { useContext } from 'use-context-selector' import ConfirmAddVar from './confirm-add-var' import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap' diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 8a02ca8caa..de7d2c9eac 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -3,7 +3,7 @@ import type { ChangeEvent, FC } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' -import produce from 'immer' +import { produce } from 'immer' import ModalFoot from '../modal-foot' import ConfigSelect from '../config-select' import ConfigString from '../config-string' diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index b9227c6846..6726498294 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -4,7 +4,7 @@ import React, { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import { useContext } from 'use-context-selector' -import produce from 'immer' +import { produce } from 'immer' import { ReactSortable } from 'react-sortablejs' import Panel from '../base/feature-panel' import EditModal from './config-modal' diff --git a/web/app/components/app/configuration/config-vision/index.tsx b/web/app/components/app/configuration/config-vision/index.tsx index f0904b3fd8..bbe322ee7e 100644 --- a/web/app/components/app/configuration/config-vision/index.tsx +++ b/web/app/components/app/configuration/config-vision/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { useContext } from 'use-context-selector' import ParamConfig from './param-config' import { Vision } from '@/app/components/base/icons/src/vender/features' diff --git a/web/app/components/app/configuration/config-vision/param-config-content.tsx b/web/app/components/app/configuration/config-vision/param-config-content.tsx index f0d8122102..359f79dd57 100644 --- a/web/app/components/app/configuration/config-vision/param-config-content.tsx +++ b/web/app/components/app/configuration/config-vision/param-config-content.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { Resolution, TransferMethod } from '@/types/app' import ParamItem from '@/app/components/base/param-item' diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index b4711ea39a..f2b9c105fc 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import copy from 'copy-to-clipboard' -import produce from 'immer' +import { produce } from 'immer' import { RiDeleteBinLine, RiEqualizer2Line, diff --git a/web/app/components/app/configuration/config/config-audio.tsx b/web/app/components/app/configuration/config/config-audio.tsx index 5600f8cbb6..5253b7c902 100644 --- a/web/app/components/app/configuration/config/config-audio.tsx +++ b/web/app/components/app/configuration/config/config-audio.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { useContext } from 'use-context-selector' import { Microphone01 } from '@/app/components/base/icons/src/vender/features' diff --git a/web/app/components/app/configuration/config/config-document.tsx b/web/app/components/app/configuration/config/config-document.tsx index 9300bbc712..c0e8cc3a2d 100644 --- a/web/app/components/app/configuration/config/config-document.tsx +++ b/web/app/components/app/configuration/config/config-document.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { useContext } from 'use-context-selector' import { Document } from '@/app/components/base/icons/src/vender/features' diff --git a/web/app/components/app/configuration/config/index.tsx b/web/app/components/app/configuration/config/index.tsx index d0375c6de9..7e130a4e95 100644 --- a/web/app/components/app/configuration/config/index.tsx +++ b/web/app/components/app/configuration/config/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React from 'react' import { useContext } from 'use-context-selector' -import produce from 'immer' +import { produce } from 'immer' import { useFormattingChangedDispatcher } from '../debug/hooks' import DatasetConfig from '../dataset-config' import HistoryPanel from '../config-prompt/conversation-history/history-panel' diff --git a/web/app/components/app/configuration/dataset-config/index.tsx b/web/app/components/app/configuration/dataset-config/index.tsx index 65ef74bc27..0c1b9349ae 100644 --- a/web/app/components/app/configuration/dataset-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/index.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { intersectionBy } from 'lodash-es' import { useContext } from 'use-context-selector' -import produce from 'immer' +import { produce } from 'immer' import { v4 as uuid4 } from 'uuid' import { useFormattingChangedDispatcher } from '../debug/hooks' import FeaturePanel from '../base/feature-panel' diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx index 9a50d1b872..ac26b82525 100644 --- a/web/app/components/app/configuration/debug/index.tsx +++ b/web/app/components/app/configuration/debug/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import { useTranslation } from 'react-i18next' import React, { useCallback, useEffect, useRef, useState } from 'react' -import produce, { setAutoFreeze } from 'immer' +import { produce, setAutoFreeze } from 'immer' import { useBoolean } from 'ahooks' import { RiAddLine, diff --git a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts index 92958cc96d..0a6ac4bb2a 100644 --- a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts +++ b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts @@ -1,6 +1,6 @@ import { useState } from 'react' import { clone } from 'lodash-es' -import produce from 'immer' +import { produce } from 'immer' import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug' import { PromptMode } from '@/models/debug' import { ModelModeType } from '@/types/app' diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 20229c9717..a1710c8f39 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -6,7 +6,7 @@ import { basePath } from '@/utils/var' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { usePathname } from 'next/navigation' -import produce from 'immer' +import { produce } from 'immer' import { useBoolean, useGetState } from 'ahooks' import { clone, isEqual } from 'lodash-es' import { CodeBracketIcon } from '@heroicons/react/20/solid' diff --git a/web/app/components/base/action-button/index.stories.tsx b/web/app/components/base/action-button/index.stories.tsx new file mode 100644 index 0000000000..c174adbc73 --- /dev/null +++ b/web/app/components/base/action-button/index.stories.tsx @@ -0,0 +1,262 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react' +import ActionButton, { ActionButtonState } from '.' + +const meta = { + title: 'Base/ActionButton', + component: ActionButton, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['xs', 'm', 'l', 'xl'], + description: 'Button size', + }, + state: { + control: 'select', + options: [ + ActionButtonState.Default, + ActionButtonState.Active, + ActionButtonState.Disabled, + ActionButtonState.Destructive, + ActionButtonState.Hover, + ], + description: 'Button state', + }, + children: { + control: 'text', + description: 'Button content', + }, + disabled: { + control: 'boolean', + description: 'Native disabled state', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Default state +export const Default: Story = { + args: { + size: 'm', + children: , + }, +} + +// With text +export const WithText: Story = { + args: { + size: 'm', + children: 'Edit', + }, +} + +// Icon with text +export const IconWithText: Story = { + args: { + size: 'm', + children: ( + <> + + Add Item + + ), + }, +} + +// Size variations +export const ExtraSmall: Story = { + args: { + size: 'xs', + children: , + }, +} + +export const Small: Story = { + args: { + size: 'xs', + children: , + }, +} + +export const Medium: Story = { + args: { + size: 'm', + children: , + }, +} + +export const Large: Story = { + args: { + size: 'l', + children: , + }, +} + +export const ExtraLarge: Story = { + args: { + size: 'xl', + children: , + }, +} + +// State variations +export const ActiveState: Story = { + args: { + size: 'm', + state: ActionButtonState.Active, + children: , + }, +} + +export const DisabledState: Story = { + args: { + size: 'm', + state: ActionButtonState.Disabled, + children: , + }, +} + +export const DestructiveState: Story = { + args: { + size: 'm', + state: ActionButtonState.Destructive, + children: , + }, +} + +export const HoverState: Story = { + args: { + size: 'm', + state: ActionButtonState.Hover, + children: , + }, +} + +// Real-world examples +export const ToolbarActions: Story = { + render: () => ( +
+ + + + + + + + + +
+ + + +
+ ), +} + +export const InlineActions: Story = { + render: () => ( +
+ Item name + + + + + + +
+ ), +} + +export const SizeComparison: Story = { + render: () => ( +
+
+ + + + XS +
+
+ + + + S +
+
+ + + + M +
+
+ + + + L +
+
+ + + + XL +
+
+ ), +} + +export const StateComparison: Story = { + render: () => ( +
+
+ + + + Default +
+
+ + + + Active +
+
+ + + + Hover +
+
+ + + + Disabled +
+
+ + + + Destructive +
+
+ ), +} + +// Interactive playground +export const Playground: Story = { + args: { + size: 'm', + state: ActionButtonState.Default, + children: , + }, +} diff --git a/web/app/components/base/auto-height-textarea/index.stories.tsx b/web/app/components/base/auto-height-textarea/index.stories.tsx new file mode 100644 index 0000000000..f083e4f56d --- /dev/null +++ b/web/app/components/base/auto-height-textarea/index.stories.tsx @@ -0,0 +1,204 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import AutoHeightTextarea from '.' + +const meta = { + title: 'Base/AutoHeightTextarea', + component: AutoHeightTextarea, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + placeholder: { + control: 'text', + description: 'Placeholder text', + }, + value: { + control: 'text', + description: 'Textarea value', + }, + minHeight: { + control: 'number', + description: 'Minimum height in pixels', + }, + maxHeight: { + control: 'number', + description: 'Maximum height in pixels', + }, + autoFocus: { + control: 'boolean', + description: 'Auto focus on mount', + }, + className: { + control: 'text', + description: 'Additional CSS classes', + }, + wrapperClassName: { + control: 'text', + description: 'Wrapper CSS classes', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const AutoHeightTextareaDemo = (args: any) => { + const [value, setValue] = useState(args.value || '') + + return ( +
+ { + setValue(e.target.value) + console.log('Text changed:', e.target.value) + }} + /> +
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: '', + minHeight: 36, + maxHeight: 96, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// With initial value +export const WithInitialValue: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: 'This is a pre-filled textarea with some initial content.', + minHeight: 36, + maxHeight: 96, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// With multiline content +export const MultilineContent: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.', + minHeight: 36, + maxHeight: 96, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Custom min height +export const CustomMinHeight: Story = { + render: args => , + args: { + placeholder: 'Taller minimum height...', + value: '', + minHeight: 100, + maxHeight: 200, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Small max height (scrollable) +export const SmallMaxHeight: Story = { + render: args => , + args: { + placeholder: 'Type multiple lines...', + value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.', + minHeight: 36, + maxHeight: 80, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Auto focus enabled +export const AutoFocus: Story = { + render: args => , + args: { + placeholder: 'This textarea auto-focuses on mount', + value: '', + minHeight: 36, + maxHeight: 96, + autoFocus: true, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// With custom styling +export const CustomStyling: Story = { + render: args => , + args: { + placeholder: 'Custom styled textarea...', + value: '', + minHeight: 50, + maxHeight: 150, + className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-none focus:bg-white focus:border-blue-600', + wrapperClassName: 'shadow-lg', + }, +} + +// Long content example +export const LongContent: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + minHeight: 36, + maxHeight: 200, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Real-world example - Chat input +export const ChatInput: Story = { + render: args => , + args: { + placeholder: 'Type your message...', + value: '', + minHeight: 40, + maxHeight: 120, + className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-blue-500', + }, +} + +// Real-world example - Comment box +export const CommentBox: Story = { + render: args => , + args: { + placeholder: 'Write a comment...', + value: '', + minHeight: 60, + maxHeight: 200, + className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500', + }, +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: '', + minHeight: 36, + maxHeight: 96, + autoFocus: false, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + wrapperClassName: '', + }, +} diff --git a/web/app/components/base/auto-height-textarea/index.tsx b/web/app/components/base/auto-height-textarea/index.tsx index da412a176d..fb64bf9db4 100644 --- a/web/app/components/base/auto-height-textarea/index.tsx +++ b/web/app/components/base/auto-height-textarea/index.tsx @@ -31,7 +31,7 @@ const AutoHeightTextarea = ( onKeyDown, onKeyUp, }: IProps & { - ref: React.RefObject; + ref?: React.RefObject; }, ) => { // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/web/app/components/base/block-input/index.stories.tsx b/web/app/components/base/block-input/index.stories.tsx new file mode 100644 index 0000000000..0685f4150f --- /dev/null +++ b/web/app/components/base/block-input/index.stories.tsx @@ -0,0 +1,191 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import BlockInput from '.' + +const meta = { + title: 'Base/BlockInput', + component: BlockInput, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: 'Input value (supports {{variable}} syntax)', + }, + className: { + control: 'text', + description: 'Wrapper CSS classes', + }, + highLightClassName: { + control: 'text', + description: 'CSS class for highlighted variables (default: text-blue-500)', + }, + readonly: { + control: 'boolean', + description: 'Read-only mode', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const BlockInputDemo = (args: any) => { + const [value, setValue] = useState(args.value || '') + const [keys, setKeys] = useState([]) + + return ( +
+ { + setValue(newValue) + setKeys(extractedKeys) + console.log('Value confirmed:', newValue) + console.log('Extracted keys:', extractedKeys) + }} + /> + {keys.length > 0 && ( +
+
Detected Variables:
+
+ {keys.map(key => ( + + {key} + + ))} +
+
+ )} +
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + value: '', + readonly: false, + }, +} + +// With single variable +export const SingleVariable: Story = { + render: args => , + args: { + value: 'Hello {{name}}, welcome to the application!', + readonly: false, + }, +} + +// With multiple variables +export const MultipleVariables: Story = { + render: args => , + args: { + value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!', + readonly: false, + }, +} + +// Complex template +export const ComplexTemplate: Story = { + render: args => , + args: { + value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.', + readonly: false, + }, +} + +// Read-only mode +export const ReadOnlyMode: Story = { + render: args => , + args: { + value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.', + readonly: true, + }, +} + +// Empty state +export const EmptyState: Story = { + render: args => , + args: { + value: '', + readonly: false, + }, +} + +// Long content +export const LongContent: Story = { + render: args => , + args: { + value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team', + readonly: false, + }, +} + +// Variables with underscores +export const VariablesWithUnderscores: Story = { + render: args => , + args: { + value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.', + readonly: false, + }, +} + +// Adjacent variables +export const AdjacentVariables: Story = { + render: args => , + args: { + value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})', + readonly: false, + }, +} + +// Real-world example - Email template +export const EmailTemplate: Story = { + render: args => , + args: { + value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team', + readonly: false, + }, +} + +// Real-world example - Notification template +export const NotificationTemplate: Story = { + render: args => , + args: { + value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}', + readonly: false, + }, +} + +// Custom styling +export const CustomStyling: Story = { + render: args => , + args: { + value: 'This template uses {{custom_variable}} with custom styling.', + readonly: false, + className: 'bg-gray-50 border-2 border-blue-200', + }, +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + value: 'Try editing this text and adding variables like {{example}}', + readonly: false, + className: '', + highLightClassName: '', + }, +} diff --git a/web/app/components/base/button/index.stories.tsx b/web/app/components/base/button/index.stories.tsx index c1b18f1e50..e51b928e5e 100644 --- a/web/app/components/base/button/index.stories.tsx +++ b/web/app/components/base/button/index.stories.tsx @@ -1,5 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react' -import { fn } from '@storybook/test' +import type { Meta, StoryObj } from '@storybook/nextjs' import { RocketLaunchIcon } from '@heroicons/react/20/solid' import { Button } from '.' @@ -20,8 +19,7 @@ const meta = { }, args: { variant: 'ghost', - onClick: fn(), - children: 'adsf', + children: 'Button', }, } satisfies Meta @@ -33,6 +31,9 @@ export const Default: Story = { variant: 'primary', loading: false, children: 'Primary Button', + styleCss: {}, + spinnerClassName: '', + destructive: false, }, } diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index c17ab26dfe..9b0f67e6b2 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -8,7 +8,7 @@ import { import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { useLocalStorageState } from 'ahooks' -import produce from 'immer' +import { produce } from 'immer' import type { Callback, ChatConfig, diff --git a/web/app/components/base/chat/chat/answer/index.stories.tsx b/web/app/components/base/chat/chat/answer/index.stories.tsx index 18bc129994..a83c0fea61 100644 --- a/web/app/components/base/chat/chat/answer/index.stories.tsx +++ b/web/app/components/base/chat/chat/answer/index.stories.tsx @@ -1,7 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/react' - +import type { Meta, StoryObj } from '@storybook/nextjs' import type { ChatItem } from '../../types' -import { mockedWorkflowProcess } from './__mocks__/workflowProcess' import { markdownContent } from './__mocks__/markdownContent' import { markdownContentSVG } from './__mocks__/markdownContentSVG' import Answer from '.' @@ -34,6 +32,11 @@ const mockedBaseChatItem = { content: 'Hello, how can I assist you today?', } satisfies ChatItem +const mockedWorkflowProcess = { + status: 'succeeded', + tracing: [], +} + export const Basic: Story = { args: { item: mockedBaseChatItem, diff --git a/web/app/components/base/chat/chat/question.stories.tsx b/web/app/components/base/chat/chat/question.stories.tsx index 9c0eb8cad8..6474add9df 100644 --- a/web/app/components/base/chat/chat/question.stories.tsx +++ b/web/app/components/base/chat/chat/question.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/nextjs' import type { ChatItem } from '../types' import Question from './question' diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 6987955b74..cfb221522c 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -8,7 +8,7 @@ import { import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { useLocalStorageState } from 'ahooks' -import produce from 'immer' +import { produce } from 'immer' import type { ChatConfig, ChatItem, diff --git a/web/app/components/base/checkbox/index.stories.tsx b/web/app/components/base/checkbox/index.stories.tsx new file mode 100644 index 0000000000..65fa8e1b97 --- /dev/null +++ b/web/app/components/base/checkbox/index.stories.tsx @@ -0,0 +1,394 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Checkbox from '.' + +// Helper function for toggling items in an array +const createToggleItem = ( + items: T[], + setItems: (items: T[]) => void, +) => (id: string) => { + setItems(items.map(item => + item.id === id ? { ...item, checked: !item.checked } as T : item, + )) +} + +const meta = { + title: 'Base/Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Checkbox component with support for checked, unchecked, indeterminate, and disabled states.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + checked: { + control: 'boolean', + description: 'Checked state', + }, + indeterminate: { + control: 'boolean', + description: 'Indeterminate state (partially checked)', + }, + disabled: { + control: 'boolean', + description: 'Disabled state', + }, + className: { + control: 'text', + description: 'Additional CSS classes', + }, + id: { + control: 'text', + description: 'HTML id attribute', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const CheckboxDemo = (args: any) => { + const [checked, setChecked] = useState(args.checked || false) + + return ( +
+ { + if (!args.disabled) { + setChecked(!checked) + console.log('Checkbox toggled:', !checked) + } + }} + /> + + {checked ? 'Checked' : 'Unchecked'} + +
+ ) +} + +// Default unchecked +export const Default: Story = { + render: args => , + args: { + checked: false, + disabled: false, + indeterminate: false, + }, +} + +// Checked state +export const Checked: Story = { + render: args => , + args: { + checked: true, + disabled: false, + indeterminate: false, + }, +} + +// Indeterminate state +export const Indeterminate: Story = { + render: args => , + args: { + checked: false, + disabled: false, + indeterminate: true, + }, +} + +// Disabled unchecked +export const DisabledUnchecked: Story = { + render: args => , + args: { + checked: false, + disabled: true, + indeterminate: false, + }, +} + +// Disabled checked +export const DisabledChecked: Story = { + render: args => , + args: { + checked: true, + disabled: true, + indeterminate: false, + }, +} + +// Disabled indeterminate +export const DisabledIndeterminate: Story = { + render: args => , + args: { + checked: false, + disabled: true, + indeterminate: true, + }, +} + +// State comparison +export const StateComparison: Story = { + render: () => ( +
+
+
+ undefined} /> + Unchecked +
+
+ undefined} /> + Checked +
+
+ undefined} /> + Indeterminate +
+
+
+
+ undefined} /> + Disabled +
+
+ undefined} /> + Disabled Checked +
+
+ undefined} /> + Disabled Indeterminate +
+
+
+ ), +} + +// With labels +const WithLabelsDemo = () => { + const [items, setItems] = useState([ + { id: '1', label: 'Enable notifications', checked: true }, + { id: '2', label: 'Enable email updates', checked: false }, + { id: '3', label: 'Enable SMS alerts', checked: false }, + ]) + + const toggleItem = createToggleItem(items, setItems) + + return ( +
+ {items.map(item => ( +
+ toggleItem(item.id)} + /> + +
+ ))} +
+ ) +} + +export const WithLabels: Story = { + render: () => , +} + +// Select all example +const SelectAllExampleDemo = () => { + const [items, setItems] = useState([ + { id: '1', label: 'Item 1', checked: false }, + { id: '2', label: 'Item 2', checked: false }, + { id: '3', label: 'Item 3', checked: false }, + ]) + + const allChecked = items.every(item => item.checked) + const someChecked = items.some(item => item.checked) + const indeterminate = someChecked && !allChecked + + const toggleAll = () => { + const newChecked = !allChecked + setItems(items.map(item => ({ ...item, checked: newChecked }))) + } + + const toggleItem = createToggleItem(items, setItems) + + return ( +
+
+ + Select All +
+
+ {items.map(item => ( +
+ toggleItem(item.id)} + /> + +
+ ))} +
+
+ ) +} + +export const SelectAllExample: Story = { + render: () => , +} + +// Form example +const FormExampleDemo = () => { + const [formData, setFormData] = useState({ + terms: false, + newsletter: false, + privacy: false, + }) + + return ( +
+

Account Settings

+
+
+ setFormData({ ...formData, terms: !formData.terms })} + /> +
+ +

+ Required to continue +

+
+
+
+ setFormData({ ...formData, newsletter: !formData.newsletter })} + /> +
+ +

+ Get updates about new features +

+
+
+
+ setFormData({ ...formData, privacy: !formData.privacy })} + /> +
+ +

+ Required to continue +

+
+
+
+
+ ) +} + +export const FormExample: Story = { + render: () => , +} + +// Task list example +const TaskListExampleDemo = () => { + const [tasks, setTasks] = useState([ + { id: '1', title: 'Review pull request', completed: true }, + { id: '2', title: 'Update documentation', completed: true }, + { id: '3', title: 'Fix navigation bug', completed: false }, + { id: '4', title: 'Deploy to staging', completed: false }, + ]) + + const toggleTask = (id: string) => { + setTasks(tasks.map(task => + task.id === id ? { ...task, completed: !task.completed } : task, + )) + } + + const completedCount = tasks.filter(t => t.completed).length + + return ( +
+
+

Today's Tasks

+ + {completedCount} of {tasks.length} completed + +
+
+ {tasks.map(task => ( +
+ toggleTask(task.id)} + /> + toggleTask(task.id)} + > + {task.title} + +
+ ))} +
+
+ ) +} + +export const TaskListExample: Story = { + render: () => , +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + checked: false, + indeterminate: false, + disabled: false, + id: 'playground-checkbox', + }, +} diff --git a/web/app/components/base/confirm/index.stories.tsx b/web/app/components/base/confirm/index.stories.tsx new file mode 100644 index 0000000000..dfbe00f293 --- /dev/null +++ b/web/app/components/base/confirm/index.stories.tsx @@ -0,0 +1,199 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Confirm from '.' +import Button from '../button' + +const meta = { + title: 'Base/Confirm', + component: Confirm, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Confirmation dialog component that supports warning and info types, with customizable button text and behavior.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + type: { + control: 'select', + options: ['info', 'warning'], + description: 'Dialog type', + }, + isShow: { + control: 'boolean', + description: 'Whether to show the dialog', + }, + title: { + control: 'text', + description: 'Dialog title', + }, + content: { + control: 'text', + description: 'Dialog content', + }, + confirmText: { + control: 'text', + description: 'Confirm button text', + }, + cancelText: { + control: 'text', + description: 'Cancel button text', + }, + isLoading: { + control: 'boolean', + description: 'Confirm button loading state', + }, + isDisabled: { + control: 'boolean', + description: 'Confirm button disabled state', + }, + showConfirm: { + control: 'boolean', + description: 'Whether to show confirm button', + }, + showCancel: { + control: 'boolean', + description: 'Whether to show cancel button', + }, + maskClosable: { + control: 'boolean', + description: 'Whether clicking mask closes dialog', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const ConfirmDemo = (args: any) => { + const [isShow, setIsShow] = useState(false) + + return ( +
+ + { + console.log('✅ User clicked confirm') + setIsShow(false) + }} + onCancel={() => { + console.log('❌ User clicked cancel') + setIsShow(false) + }} + /> +
+ ) +} + +// Basic warning dialog - Delete action +export const WarningDialog: Story = { + render: args => , + args: { + type: 'warning', + title: 'Delete Confirmation', + content: 'Are you sure you want to delete this project? This action cannot be undone.', + }, +} + +// Info dialog +export const InfoDialog: Story = { + render: args => , + args: { + type: 'info', + title: 'Notice', + content: 'Your changes have been saved. Do you want to proceed to the next step?', + }, +} + +// Custom button text +export const CustomButtonText: Story = { + render: args => , + args: { + type: 'warning', + title: 'Exit Editor', + content: 'You have unsaved changes. Are you sure you want to exit?', + confirmText: 'Discard Changes', + cancelText: 'Continue Editing', + }, +} + +// Loading state +export const LoadingState: Story = { + render: args => , + args: { + type: 'warning', + title: 'Deleting...', + content: 'Please wait while we delete the file...', + isLoading: true, + }, +} + +// Disabled state +export const DisabledState: Story = { + render: args => , + args: { + type: 'info', + title: 'Verification Required', + content: 'Please complete email verification before proceeding.', + isDisabled: true, + }, +} + +// Alert style - Confirm button only +export const AlertStyle: Story = { + render: args => , + args: { + type: 'info', + title: 'Success', + content: 'Your settings have been updated!', + showCancel: false, + confirmText: 'Got it', + }, +} + +// Dangerous action - Long content +export const DangerousAction: Story = { + render: args => , + args: { + type: 'warning', + title: 'Permanently Delete Account', + content: 'This action will permanently delete your account and all associated data, including: all projects and files, collaboration history, and personal settings. This action cannot be reversed!', + confirmText: 'Delete My Account', + cancelText: 'Keep My Account', + }, +} + +// Non-closable mask +export const NotMaskClosable: Story = { + render: args => , + args: { + type: 'warning', + title: 'Important Action', + content: 'This action requires your explicit choice. Clicking outside will not close this dialog.', + maskClosable: false, + }, +} + +// Full feature demo - Playground +export const Playground: Story = { + render: args => , + args: { + type: 'warning', + title: 'This is a title', + content: 'This is the dialog content text...', + confirmText: undefined, + cancelText: undefined, + isLoading: false, + isDisabled: false, + showConfirm: true, + showCancel: true, + maskClosable: true, + }, +} diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx index 34ecf23c80..140c900cb4 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { usePathname, useRouter } from 'next/navigation' -import produce from 'immer' +import { produce } from 'immer' import { RiEqualizer2Line, RiExternalLinkLine } from '@remixicon/react' import { MessageFast } from '@/app/components/base/icons/src/vender/features' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts index 540302cb27..14b977a4c8 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import produce from 'immer' +import { produce } from 'immer' import type { AnnotationReplyConfig } from '@/models/debug' import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation' import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type' diff --git a/web/app/components/base/features/new-feature-panel/citation.tsx b/web/app/components/base/features/new-feature-panel/citation.tsx index 773304d6ae..fc61d1afb5 100644 --- a/web/app/components/base/features/new-feature-panel/citation.tsx +++ b/web/app/components/base/features/new-feature-panel/citation.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { Citations } from '@/app/components/base/icons/src/vender/features' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx index e4075880e9..4016b9f163 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { RiEditLine } from '@remixicon/react' import { LoveMessage } from '@/app/components/base/icons/src/vender/features' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index ec8681f37c..f0af893f0d 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' -import produce from 'immer' +import { produce } from 'immer' import { ReactSortable } from 'react-sortablejs' import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react' import Modal from '@/app/components/base/modal' diff --git a/web/app/components/base/features/new-feature-panel/file-upload/index.tsx b/web/app/components/base/features/new-feature-panel/file-upload/index.tsx index 1fc1bff511..7561936130 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/index.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { RiEqualizer2Line } from '@remixicon/react' import { FolderUpload } from '@/app/components/base/icons/src/vender/features' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx index 5a53aa403d..cb0821bb7b 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import produce from 'immer' +import { produce } from 'immer' import { useTranslation } from 'react-i18next' import { RiCloseLine } from '@remixicon/react' import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' diff --git a/web/app/components/base/features/new-feature-panel/follow-up.tsx b/web/app/components/base/features/new-feature-panel/follow-up.tsx index a81bc94894..1abea971f2 100644 --- a/web/app/components/base/features/new-feature-panel/follow-up.tsx +++ b/web/app/components/base/features/new-feature-panel/follow-up.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { VirtualAssistant } from '@/app/components/base/icons/src/vender/features' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' diff --git a/web/app/components/base/features/new-feature-panel/image-upload/index.tsx b/web/app/components/base/features/new-feature-panel/image-upload/index.tsx index f09c35a01e..53c0fa9fb1 100644 --- a/web/app/components/base/features/new-feature-panel/image-upload/index.tsx +++ b/web/app/components/base/features/new-feature-panel/image-upload/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { RiEqualizer2Line, RiImage2Fill } from '@remixicon/react' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import SettingModal from '@/app/components/base/features/new-feature-panel/file-upload/setting-modal' diff --git a/web/app/components/base/features/new-feature-panel/moderation/index.tsx b/web/app/components/base/features/new-feature-panel/moderation/index.tsx index 78f4f2d0ab..b5bcbca474 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/index.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/index.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' -import produce from 'immer' +import { produce } from 'immer' import { useContext } from 'use-context-selector' import { RiEqualizer2Line } from '@remixicon/react' import { ContentModeration } from '@/app/components/base/icons/src/vender/features' diff --git a/web/app/components/base/features/new-feature-panel/more-like-this.tsx b/web/app/components/base/features/new-feature-panel/more-like-this.tsx index d2e2fc64b0..20e5abff0d 100644 --- a/web/app/components/base/features/new-feature-panel/more-like-this.tsx +++ b/web/app/components/base/features/new-feature-panel/more-like-this.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { RiSparklingFill } from '@remixicon/react' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' diff --git a/web/app/components/base/features/new-feature-panel/speech-to-text.tsx b/web/app/components/base/features/new-feature-panel/speech-to-text.tsx index 7905f8a43b..2c5f9d0f53 100644 --- a/web/app/components/base/features/new-feature-panel/speech-to-text.tsx +++ b/web/app/components/base/features/new-feature-panel/speech-to-text.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { Microphone01 } from '@/app/components/base/icons/src/vender/features' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx index 340f9ae626..7d5d39cdb1 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import produce from 'immer' +import { produce } from 'immer' import { RiEqualizer2Line } from '@remixicon/react' import { TextToAudio } from '@/app/components/base/icons/src/vender/features' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index 6e93c0c871..b14417e665 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -1,6 +1,6 @@ 'use client' import useSWR from 'swr' -import produce from 'immer' +import { produce } from 'immer' import React, { Fragment } from 'react' import { usePathname } from 'next/navigation' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index d3c79a9f45..3f4d4a6b06 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -4,7 +4,7 @@ import { useState, } from 'react' import { useParams } from 'next/navigation' -import produce from 'immer' +import { produce } from 'immer' import { v4 as uuid4 } from 'uuid' import { useTranslation } from 'react-i18next' import type { FileEntity } from './types' diff --git a/web/app/components/base/icons/assets/vender/line/mapsAndTravel/route.svg b/web/app/components/base/icons/assets/vender/line/mapsAndTravel/route.svg deleted file mode 100644 index b1543ccc13..0000000000 --- a/web/app/components/base/icons/assets/vender/line/mapsAndTravel/route.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/web/app/components/base/icons/assets/vender/solid/mapsAndTravel/globe-06.svg b/web/app/components/base/icons/assets/vender/solid/mapsAndTravel/globe-06.svg deleted file mode 100644 index 45f3778f0b..0000000000 --- a/web/app/components/base/icons/assets/vender/solid/mapsAndTravel/globe-06.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/web/app/components/base/icons/assets/vender/solid/mapsAndTravel/route.svg b/web/app/components/base/icons/assets/vender/solid/mapsAndTravel/route.svg deleted file mode 100644 index b647dfc753..0000000000 --- a/web/app/components/base/icons/assets/vender/solid/mapsAndTravel/route.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/web/app/components/base/icons/src/vender/line/communication/AiText.json b/web/app/components/base/icons/src/vender/line/communication/AiText.json deleted file mode 100644 index 2473c64c22..0000000000 --- a/web/app/components/base/icons/src/vender/line/communication/AiText.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "icon": { - "type": "element", - "isRootNode": true, - "name": "svg", - "attributes": { - "width": "14", - "height": "14", - "viewBox": "0 0 14 14", - "fill": "none", - "xmlns": "http://www.w3.org/2000/svg" - }, - "children": [ - { - "type": "element", - "name": "g", - "attributes": { - "id": "ai-text" - }, - "children": [ - { - "type": "element", - "name": "path", - "attributes": { - "id": "Vector", - "d": "M2.33301 10.5H4.08301M2.33301 7H5.24967M2.33301 3.5H11.6663M9.91634 5.83333L10.7913 7.875L12.833 8.75L10.7913 9.625L9.91634 11.6667L9.04134 9.625L6.99967 8.75L9.04134 7.875L9.91634 5.83333Z", - "stroke": "currentColor", - "stroke-width": "1.25", - "stroke-linecap": "round", - "stroke-linejoin": "round" - }, - "children": [] - } - ] - } - ] - }, - "name": "AiText" -} diff --git a/web/app/components/base/icons/src/vender/line/communication/AiText.tsx b/web/app/components/base/icons/src/vender/line/communication/AiText.tsx deleted file mode 100644 index 7d5a860038..0000000000 --- a/web/app/components/base/icons/src/vender/line/communication/AiText.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATE BY script -// DON NOT EDIT IT MANUALLY - -import * as React from 'react' -import data from './AiText.json' -import IconBase from '@/app/components/base/icons/IconBase' -import type { IconData } from '@/app/components/base/icons/IconBase' - -const Icon = ( - { - ref, - ...props - }: React.SVGProps & { - ref?: React.RefObject>; - }, -) => - -Icon.displayName = 'AiText' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/line/communication/index.ts b/web/app/components/base/icons/src/vender/line/communication/index.ts index 3ab20e8bb4..27118f1dde 100644 --- a/web/app/components/base/icons/src/vender/line/communication/index.ts +++ b/web/app/components/base/icons/src/vender/line/communication/index.ts @@ -1,4 +1,3 @@ -export { default as AiText } from './AiText' export { default as ChatBotSlim } from './ChatBotSlim' export { default as ChatBot } from './ChatBot' export { default as CuteRobot } from './CuteRobot' diff --git a/web/app/components/base/icons/src/vender/line/mapsAndTravel/Route.json b/web/app/components/base/icons/src/vender/line/mapsAndTravel/Route.json deleted file mode 100644 index cb0b7f01a9..0000000000 --- a/web/app/components/base/icons/src/vender/line/mapsAndTravel/Route.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "icon": { - "type": "element", - "isRootNode": true, - "name": "svg", - "attributes": { - "width": "14", - "height": "14", - "viewBox": "0 0 14 14", - "fill": "none", - "xmlns": "http://www.w3.org/2000/svg" - }, - "children": [ - { - "type": "element", - "name": "g", - "attributes": { - "id": "route", - "clip-path": "url(#clip0_3167_28693)" - }, - "children": [ - { - "type": "element", - "name": "path", - "attributes": { - "id": "Icon", - "d": "M6.70866 2.91699H6.96206C8.73962 2.91699 9.6284 2.91699 9.96578 3.23624C10.2574 3.51221 10.3867 3.91874 10.3079 4.31245C10.2168 4.76792 9.49122 5.28116 8.03999 6.30763L5.66899 7.98468C4.21777 9.01116 3.49215 9.5244 3.40106 9.97987C3.32233 10.3736 3.45157 10.7801 3.7432 11.0561C4.08059 11.3753 4.96937 11.3753 6.74693 11.3753H7.29199M4.66699 2.91699C4.66699 3.88349 3.88349 4.66699 2.91699 4.66699C1.95049 4.66699 1.16699 3.88349 1.16699 2.91699C1.16699 1.95049 1.95049 1.16699 2.91699 1.16699C3.88349 1.16699 4.66699 1.95049 4.66699 2.91699ZM12.8337 11.0837C12.8337 12.0502 12.0502 12.8337 11.0837 12.8337C10.1172 12.8337 9.33366 12.0502 9.33366 11.0837C9.33366 10.1172 10.1172 9.33366 11.0837 9.33366C12.0502 9.33366 12.8337 10.1172 12.8337 11.0837Z", - "stroke": "currentColor", - "stroke-width": "1.25", - "stroke-linecap": "round", - "stroke-linejoin": "round" - }, - "children": [] - } - ] - }, - { - "type": "element", - "name": "defs", - "attributes": {}, - "children": [ - { - "type": "element", - "name": "clipPath", - "attributes": { - "id": "clip0_3167_28693" - }, - "children": [ - { - "type": "element", - "name": "rect", - "attributes": { - "width": "14", - "height": "14", - "fill": "white" - }, - "children": [] - } - ] - } - ] - } - ] - }, - "name": "Route" -} diff --git a/web/app/components/base/icons/src/vender/solid/communication/AiText.json b/web/app/components/base/icons/src/vender/solid/communication/AiText.json deleted file mode 100644 index 65860e58b9..0000000000 --- a/web/app/components/base/icons/src/vender/solid/communication/AiText.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "icon": { - "type": "element", - "isRootNode": true, - "name": "svg", - "attributes": { - "width": "24", - "height": "24", - "viewBox": "0 0 24 24", - "fill": "none", - "xmlns": "http://www.w3.org/2000/svg" - }, - "children": [ - { - "type": "element", - "name": "path", - "attributes": { - "d": "M4 5C3.44772 5 3 5.44772 3 6C3 6.55228 3.44772 7 4 7H20C20.5523 7 21 6.55228 21 6C21 5.44772 20.5523 5 20 5H4Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M17.9191 9.60608C17.7616 9.2384 17.4 9 17 9C16.6 9 16.2384 9.2384 16.0809 9.60608L14.7384 12.7384L11.6061 14.0809C11.2384 14.2384 11 14.6 11 15C11 15.4 11.2384 15.7616 11.6061 15.9191L14.7384 17.2616L16.0809 20.3939C16.2384 20.7616 16.6 21 17 21C17.4 21 17.7616 20.7616 17.9191 20.3939L19.2616 17.2616L22.3939 15.9191C22.7616 15.7616 23 15.4 23 15C23 14.6 22.7616 14.2384 22.3939 14.0809L19.2616 12.7384L17.9191 9.60608Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M4 11C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H9C9.55228 13 10 12.5523 10 12C10 11.4477 9.55228 11 9 11H4Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M4 17C3.44772 17 3 17.4477 3 18C3 18.5523 3.44772 19 4 19H7C7.55228 19 8 18.5523 8 18C8 17.4477 7.55228 17 7 17H4Z", - "fill": "currentColor" - }, - "children": [] - } - ] - }, - "name": "AiText" -} diff --git a/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx b/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx deleted file mode 100644 index 7d5a860038..0000000000 --- a/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATE BY script -// DON NOT EDIT IT MANUALLY - -import * as React from 'react' -import data from './AiText.json' -import IconBase from '@/app/components/base/icons/IconBase' -import type { IconData } from '@/app/components/base/icons/IconBase' - -const Icon = ( - { - ref, - ...props - }: React.SVGProps & { - ref?: React.RefObject>; - }, -) => - -Icon.displayName = 'AiText' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/communication/index.ts b/web/app/components/base/icons/src/vender/solid/communication/index.ts index 7d2a3a5a95..a1659b7b18 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/index.ts +++ b/web/app/components/base/icons/src/vender/solid/communication/index.ts @@ -1,4 +1,3 @@ -export { default as AiText } from './AiText' export { default as BubbleTextMod } from './BubbleTextMod' export { default as ChatBot } from './ChatBot' export { default as CuteRobot } from './CuteRobot' diff --git a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Globe06.json b/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Globe06.json deleted file mode 100644 index b86197ae7e..0000000000 --- a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Globe06.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "icon": { - "type": "element", - "isRootNode": true, - "name": "svg", - "attributes": { - "width": "16", - "height": "17", - "viewBox": "0 0 16 17", - "fill": "none", - "xmlns": "http://www.w3.org/2000/svg" - }, - "children": [ - { - "type": "element", - "name": "g", - "attributes": { - "id": "Icon" - }, - "children": [ - { - "type": "element", - "name": "g", - "attributes": { - "id": "Solid" - }, - "children": [ - { - "type": "element", - "name": "path", - "attributes": { - "fill-rule": "evenodd", - "clip-rule": "evenodd", - "d": "M6.39498 2.71706C6.90587 2.57557 7.44415 2.49996 8.00008 2.49996C9.30806 2.49996 10.5183 2.91849 11.5041 3.62893C10.9796 3.97562 10.5883 4.35208 10.3171 4.75458C9.90275 5.36959 9.79654 6.00558 9.88236 6.58587C9.96571 7.1494 10.2245 7.63066 10.4965 7.98669C10.7602 8.33189 11.0838 8.6206 11.3688 8.76305C12.0863 9.12177 12.9143 9.30141 13.5334 9.39399C14.0933 9.47774 15.2805 9.75802 15.3244 8.86608C15.3304 8.74474 15.3334 8.62267 15.3334 8.49996C15.3334 4.44987 12.0502 1.16663 8.00008 1.16663C3.94999 1.16663 0.666748 4.44987 0.666748 8.49996C0.666748 12.55 3.94999 15.8333 8.00008 15.8333C8.1228 15.8333 8.24486 15.8303 8.3662 15.8243C8.73395 15.8062 9.01738 15.4934 8.99927 15.1256C8.98117 14.7579 8.66837 14.4745 8.30063 14.4926C8.20111 14.4975 8.10091 14.5 8.00008 14.5C5.6605 14.5 3.63367 13.1609 2.6442 11.2074L3.28991 10.8346L5.67171 11.2804C6.28881 11.3959 6.85846 10.9208 6.85566 10.293L6.84632 8.19093L8.06357 6.10697C8.26079 5.76932 8.24312 5.3477 8.01833 5.02774L6.39498 2.71706Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "fill-rule": "evenodd", - "clip-rule": "evenodd", - "d": "M9.29718 8.93736C9.05189 8.84432 8.77484 8.90379 8.58934 9.08929C8.40383 9.27479 8.34437 9.55184 8.43741 9.79713L10.5486 15.363C10.6461 15.6199 10.8912 15.7908 11.166 15.7932C11.4408 15.7956 11.689 15.6292 11.791 15.374L12.6714 13.1714L14.874 12.2909C15.1292 12.1889 15.2957 11.9408 15.2932 11.666C15.2908 11.3912 15.12 11.146 14.863 11.0486L9.29718 8.93736Z", - "fill": "currentColor" - }, - "children": [] - } - ] - } - ] - } - ] - }, - "name": "Globe06" -} diff --git a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Globe06.tsx b/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Globe06.tsx deleted file mode 100644 index af5d2a8d52..0000000000 --- a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Globe06.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATE BY script -// DON NOT EDIT IT MANUALLY - -import * as React from 'react' -import data from './Globe06.json' -import IconBase from '@/app/components/base/icons/IconBase' -import type { IconData } from '@/app/components/base/icons/IconBase' - -const Icon = ( - { - ref, - ...props - }: React.SVGProps & { - ref?: React.RefObject>; - }, -) => - -Icon.displayName = 'Globe06' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Route.json b/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Route.json deleted file mode 100644 index ac94bf2109..0000000000 --- a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Route.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "icon": { - "type": "element", - "isRootNode": true, - "name": "svg", - "attributes": { - "width": "13", - "height": "12", - "viewBox": "0 0 13 12", - "fill": "none", - "xmlns": "http://www.w3.org/2000/svg" - }, - "children": [ - { - "type": "element", - "name": "g", - "attributes": { - "id": "route-sep" - }, - "children": [ - { - "type": "element", - "name": "path", - "attributes": { - "id": "Icon", - "d": "M6.08303 2.5H6.30023C7.82386 2.5 8.58567 2.5 8.87485 2.77364C9.12483 3.01018 9.23561 3.35864 9.16812 3.69611C9.09004 4.08651 8.46809 4.52643 7.22418 5.40627L5.19189 6.84373C3.94799 7.72357 3.32603 8.16349 3.24795 8.55389C3.18046 8.89136 3.29124 9.23982 3.54122 9.47636C3.8304 9.75 4.59221 9.75 6.11584 9.75H6.58303", - "stroke": "currentColor", - "stroke-linecap": "round", - "stroke-linejoin": "round" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "id": "Icon_2", - "d": "M2.83301 4C3.66143 4 4.33301 3.32843 4.33301 2.5C4.33301 1.67157 3.66143 1 2.83301 1C2.00458 1 1.33301 1.67157 1.33301 2.5C1.33301 3.32843 2.00458 4 2.83301 4Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "id": "Icon_3", - "d": "M9.83301 11C10.6614 11 11.333 10.3284 11.333 9.5C11.333 8.67157 10.6614 8 9.83301 8C9.00458 8 8.33301 8.67157 8.33301 9.5C8.33301 10.3284 9.00458 11 9.83301 11Z", - "fill": "currentColor" - }, - "children": [] - } - ] - } - ] - }, - "name": "Route" -} diff --git a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Route.tsx b/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Route.tsx deleted file mode 100644 index 9cbde4a15e..0000000000 --- a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/Route.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// GENERATE BY script -// DON NOT EDIT IT MANUALLY - -import * as React from 'react' -import data from './Route.json' -import IconBase from '@/app/components/base/icons/IconBase' -import type { IconData } from '@/app/components/base/icons/IconBase' - -const Icon = ( - { - ref, - ...props - }: React.SVGProps & { - ref?: React.RefObject>; - }, -) => - -Icon.displayName = 'Route' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/index.ts b/web/app/components/base/icons/src/vender/solid/mapsAndTravel/index.ts deleted file mode 100644 index 0a0abda63c..0000000000 --- a/web/app/components/base/icons/src/vender/solid/mapsAndTravel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Globe06 } from './Globe06' -export { default as Route } from './Route' diff --git a/web/app/components/base/icons/utils.ts b/web/app/components/base/icons/utils.ts index 632e362075..c00c201bbd 100644 --- a/web/app/components/base/icons/utils.ts +++ b/web/app/components/base/icons/utils.ts @@ -3,13 +3,13 @@ import React from 'react' export type AbstractNode = { name: string attributes: { - [key: string]: string + [key: string]: string | undefined } children?: AbstractNode[] } export type Attrs = { - [key: string]: string + [key: string]: string | undefined } export function normalizeAttrs(attrs: Attrs = {}): Attrs { @@ -24,6 +24,9 @@ export function normalizeAttrs(attrs: Attrs = {}): Attrs { return acc const val = attrs[key] + if (val === undefined) + return acc + key = key.replace(/([-]\w)/g, (g: string) => g[1].toUpperCase()) key = key.replace(/([:]\w)/g, (g: string) => g[1].toUpperCase()) diff --git a/web/app/components/base/input-number/index.stories.tsx b/web/app/components/base/input-number/index.stories.tsx new file mode 100644 index 0000000000..0fca2e52f9 --- /dev/null +++ b/web/app/components/base/input-number/index.stories.tsx @@ -0,0 +1,438 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { InputNumber } from '.' + +const meta = { + title: 'Base/InputNumber', + component: InputNumber, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'number', + description: 'Current value', + }, + size: { + control: 'select', + options: ['regular', 'large'], + description: 'Input size', + }, + min: { + control: 'number', + description: 'Minimum value', + }, + max: { + control: 'number', + description: 'Maximum value', + }, + amount: { + control: 'number', + description: 'Step amount for increment/decrement', + }, + unit: { + control: 'text', + description: 'Unit text displayed (e.g., "px", "ms")', + }, + disabled: { + control: 'boolean', + description: 'Disabled state', + }, + defaultValue: { + control: 'number', + description: 'Default value when undefined', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const InputNumberDemo = (args: any) => { + const [value, setValue] = useState(args.value ?? 0) + + return ( +
+ { + setValue(newValue) + console.log('Value changed:', newValue) + }} + /> +
+ Current value: {value} +
+
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + value: 0, + size: 'regular', + }, +} + +// Large size +export const LargeSize: Story = { + render: args => , + args: { + value: 10, + size: 'large', + }, +} + +// With min/max constraints +export const WithMinMax: Story = { + render: args => , + args: { + value: 5, + min: 0, + max: 10, + size: 'regular', + }, +} + +// With custom step amount +export const CustomStepAmount: Story = { + render: args => , + args: { + value: 50, + amount: 5, + min: 0, + max: 100, + size: 'regular', + }, +} + +// With unit +export const WithUnit: Story = { + render: args => , + args: { + value: 100, + unit: 'px', + min: 0, + max: 1000, + amount: 10, + size: 'regular', + }, +} + +// Disabled state +export const Disabled: Story = { + render: args => , + args: { + value: 42, + disabled: true, + size: 'regular', + }, +} + +// Decimal values +export const DecimalValues: Story = { + render: args => , + args: { + value: 2.5, + amount: 0.5, + min: 0, + max: 10, + size: 'regular', + }, +} + +// Negative values allowed +export const NegativeValues: Story = { + render: args => , + args: { + value: 0, + min: -100, + max: 100, + amount: 10, + size: 'regular', + }, +} + +// Size comparison +const SizeComparisonDemo = () => { + const [regularValue, setRegularValue] = useState(10) + const [largeValue, setLargeValue] = useState(20) + + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +export const SizeComparison: Story = { + render: () => , +} + +// Real-world example - Font size picker +const FontSizePickerDemo = () => { + const [fontSize, setFontSize] = useState(16) + + return ( +
+
+
+ + +
+
+

+ Preview Text +

+
+
+
+ ) +} + +export const FontSizePicker: Story = { + render: () => , +} + +// Real-world example - Quantity selector +const QuantitySelectorDemo = () => { + const [quantity, setQuantity] = useState(1) + const pricePerItem = 29.99 + const total = (quantity * pricePerItem).toFixed(2) + + return ( +
+
+
+
+

Product Name

+

${pricePerItem} each

+
+
+
+ + +
+
+
+ Total + ${total} +
+
+
+
+ ) +} + +export const QuantitySelector: Story = { + render: () => , +} + +// Real-world example - Timer settings +const TimerSettingsDemo = () => { + const [hours, setHours] = useState(0) + const [minutes, setMinutes] = useState(15) + const [seconds, setSeconds] = useState(30) + + const totalSeconds = hours * 3600 + minutes * 60 + seconds + + return ( +
+

Timer Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Total duration: {totalSeconds} seconds +
+
+
+
+ ) +} + +export const TimerSettings: Story = { + render: () => , +} + +// Real-world example - Animation settings +const AnimationSettingsDemo = () => { + const [duration, setDuration] = useState(300) + const [delay, setDelay] = useState(0) + const [iterations, setIterations] = useState(1) + + return ( +
+

Animation Properties

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ animation: {duration}ms {delay}ms {iterations} +
+
+
+
+ ) +} + +export const AnimationSettings: Story = { + render: () => , +} + +// Real-world example - Temperature control +const TemperatureControlDemo = () => { + const [temperature, setTemperature] = useState(20) + const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1) + + return ( +
+

Temperature Control

+
+
+ + +
+
+
+
Celsius
+
{temperature}°C
+
+
+
Fahrenheit
+
{fahrenheit}°F
+
+
+
+
+ ) +} + +export const TemperatureControl: Story = { + render: () => , +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + value: 10, + size: 'regular', + min: 0, + max: 100, + amount: 1, + unit: '', + disabled: false, + defaultValue: 0, + }, +} diff --git a/web/app/components/base/input/index.stories.tsx b/web/app/components/base/input/index.stories.tsx new file mode 100644 index 0000000000..cd857bc180 --- /dev/null +++ b/web/app/components/base/input/index.stories.tsx @@ -0,0 +1,424 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Input from '.' + +const meta = { + title: 'Base/Input', + component: Input, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['regular', 'large'], + description: 'Input size', + }, + type: { + control: 'select', + options: ['text', 'number', 'email', 'password', 'url', 'tel'], + description: 'Input type', + }, + placeholder: { + control: 'text', + description: 'Placeholder text', + }, + disabled: { + control: 'boolean', + description: 'Disabled state', + }, + destructive: { + control: 'boolean', + description: 'Error/destructive state', + }, + showLeftIcon: { + control: 'boolean', + description: 'Show search icon on left', + }, + showClearIcon: { + control: 'boolean', + description: 'Show clear button when input has value', + }, + unit: { + control: 'text', + description: 'Unit text displayed on right (e.g., "px", "ms")', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const InputDemo = (args: any) => { + const [value, setValue] = useState(args.value || '') + + return ( +
+ { + setValue(e.target.value) + console.log('Input changed:', e.target.value) + }} + onClear={() => { + setValue('') + console.log('Input cleared') + }} + /> +
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + size: 'regular', + placeholder: 'Enter text...', + type: 'text', + }, +} + +// Large size +export const LargeSize: Story = { + render: args => , + args: { + size: 'large', + placeholder: 'Enter text...', + type: 'text', + }, +} + +// With search icon +export const WithSearchIcon: Story = { + render: args => , + args: { + size: 'regular', + showLeftIcon: true, + placeholder: 'Search...', + type: 'text', + }, +} + +// With clear button +export const WithClearButton: Story = { + render: args => , + args: { + size: 'regular', + showClearIcon: true, + value: 'Some text to clear', + placeholder: 'Type something...', + type: 'text', + }, +} + +// Search input (icon + clear) +export const SearchInput: Story = { + render: args => , + args: { + size: 'regular', + showLeftIcon: true, + showClearIcon: true, + value: '', + placeholder: 'Search...', + type: 'text', + }, +} + +// Disabled state +export const Disabled: Story = { + render: args => , + args: { + size: 'regular', + value: 'Disabled input', + disabled: true, + type: 'text', + }, +} + +// Destructive/error state +export const DestructiveState: Story = { + render: args => , + args: { + size: 'regular', + value: 'invalid@email', + destructive: true, + placeholder: 'Enter email...', + type: 'email', + }, +} + +// Number input +export const NumberInput: Story = { + render: args => , + args: { + size: 'regular', + type: 'number', + placeholder: 'Enter a number...', + value: '0', + }, +} + +// With unit +export const WithUnit: Story = { + render: args => , + args: { + size: 'regular', + type: 'number', + value: '100', + unit: 'px', + placeholder: 'Enter value...', + }, +} + +// Email input +export const EmailInput: Story = { + render: args => , + args: { + size: 'regular', + type: 'email', + placeholder: 'Enter your email...', + showClearIcon: true, + }, +} + +// Password input +export const PasswordInput: Story = { + render: args => , + args: { + size: 'regular', + type: 'password', + placeholder: 'Enter password...', + value: 'secret123', + }, +} + +// Size comparison +const SizeComparisonDemo = () => { + const [regularValue, setRegularValue] = useState('') + const [largeValue, setLargeValue] = useState('') + + return ( +
+
+ + setRegularValue(e.target.value)} + placeholder="Regular input..." + showClearIcon + onClear={() => setRegularValue('')} + /> +
+
+ + setLargeValue(e.target.value)} + placeholder="Large input..." + showClearIcon + onClear={() => setLargeValue('')} + /> +
+
+ ) +} + +export const SizeComparison: Story = { + render: () => , +} + +// State comparison +const StateComparisonDemo = () => { + const [normalValue, setNormalValue] = useState('Normal state') + const [errorValue, setErrorValue] = useState('Error state') + + return ( +
+
+ + setNormalValue(e.target.value)} + showClearIcon + onClear={() => setNormalValue('')} + /> +
+
+ + setErrorValue(e.target.value)} + destructive + /> +
+
+ + undefined} + disabled + /> +
+
+ ) +} + +export const StateComparison: Story = { + render: () => , +} + +// Form example +const FormExampleDemo = () => { + const [formData, setFormData] = useState({ + name: '', + email: '', + age: '', + website: '', + }) + const [errors, setErrors] = useState({ + email: false, + age: false, + }) + + const validateEmail = (email: string) => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + } + + return ( +
+

User Profile

+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Enter your name..." + showClearIcon + onClear={() => setFormData({ ...formData, name: '' })} + /> +
+
+ + { + setFormData({ ...formData, email: e.target.value }) + setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false }) + }} + placeholder="Enter your email..." + destructive={errors.email} + showClearIcon + onClear={() => { + setFormData({ ...formData, email: '' }) + setErrors({ ...errors, email: false }) + }} + /> + {errors.email && ( + Please enter a valid email address + )} +
+
+ + { + setFormData({ ...formData, age: e.target.value }) + setErrors({ ...errors, age: e.target.value ? Number(e.target.value) < 18 : false }) + }} + placeholder="Enter your age..." + destructive={errors.age} + unit="years" + /> + {errors.age && ( + Must be 18 or older + )} +
+
+ + setFormData({ ...formData, website: e.target.value })} + placeholder="https://example.com" + showClearIcon + onClear={() => setFormData({ ...formData, website: '' })} + /> +
+
+
+ ) +} + +export const FormExample: Story = { + render: () => , +} + +// Search example +const SearchExampleDemo = () => { + const [searchQuery, setSearchQuery] = useState('') + const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape'] + const filteredItems = items.filter(item => + item.toLowerCase().includes(searchQuery.toLowerCase()), + ) + + return ( +
+ setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + placeholder="Search fruits..." + /> + {searchQuery && ( +
+
+ {filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''} +
+
+ {filteredItems.map(item => ( +
+ {item} +
+ ))} +
+
+ )} +
+ ) +} + +export const SearchExample: Story = { + render: () => , +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + size: 'regular', + type: 'text', + placeholder: 'Type something...', + disabled: false, + destructive: false, + showLeftIcon: false, + showClearIcon: true, + unit: '', + }, +} diff --git a/web/app/components/base/prompt-editor/index.stories.tsx b/web/app/components/base/prompt-editor/index.stories.tsx new file mode 100644 index 0000000000..17b04e4af0 --- /dev/null +++ b/web/app/components/base/prompt-editor/index.stories.tsx @@ -0,0 +1,360 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' + +// Mock component to avoid complex initialization issues +const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, className, wrapperClassName }: any) => { + const [content, setContent] = useState(value || '') + + const handleChange = (e: React.ChangeEvent) => { + setContent(e.target.value) + onChange?.(e.target.value) + } + + return ( +
+