diff --git a/.github/workflows/docker_test.yml b/.github/workflows/docker_test.yml index b68f99a079..381dd9dac6 100644 --- a/.github/workflows/docker_test.yml +++ b/.github/workflows/docker_test.yml @@ -70,6 +70,18 @@ jobs: exit 1 fi + - name: Cleanup after main image + run: | + echo "=== Disk usage before cleanup ===" + df -h / + ${{ env.CONTAINER_RUNTIME }} rmi langflowai/langflow:latest-dev || true + ${{ env.CONTAINER_RUNTIME }} system prune -af || true + if command -v docker &> /dev/null; then + docker buildx prune -af || true + fi + echo "=== Disk usage after cleanup ===" + df -h / + - name: Build backend image run: | ${{ env.CONTAINER_RUNTIME }} build -t langflowai/langflow-backend:latest-dev \ @@ -85,6 +97,19 @@ jobs: echo "Actual version: $version" exit 1 fi + + - name: Cleanup after backend image + run: | + echo "=== Disk usage before cleanup ===" + df -h / + ${{ env.CONTAINER_RUNTIME }} rmi langflowai/langflow-backend:latest-dev || true + ${{ env.CONTAINER_RUNTIME }} system prune -af || true + if command -v docker &> /dev/null; then + docker buildx prune -af || true + fi + echo "=== Disk usage after cleanup ===" + df -h / + - name: Build frontend image run: | ${{ env.CONTAINER_RUNTIME }} build -t langflowai/langflow-frontend:latest-dev \ diff --git a/docker/build_and_push.Dockerfile b/docker/build_and_push.Dockerfile index 9764afec7f..df02db58e5 100644 --- a/docker/build_and_push.Dockerfile +++ b/docker/build_and_push.Dockerfile @@ -59,7 +59,7 @@ COPY src/frontend /tmp/src/frontend WORKDIR /tmp/src/frontend RUN --mount=type=cache,target=/root/.npm \ npm ci \ - && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=12288" JOBS=1 npm run build \ + && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=4096" JOBS=1 npm run build \ && cp -r build /app/src/backend/langflow/frontend \ && rm -rf /tmp/src/frontend diff --git a/docker/build_and_push_backend.Dockerfile b/docker/build_and_push_backend.Dockerfile index d8da3eec1f..232c05af89 100644 --- a/docker/build_and_push_backend.Dockerfile +++ b/docker/build_and_push_backend.Dockerfile @@ -78,7 +78,8 @@ ENV PATH="/app/.venv/bin:$PATH" # Create home directory and ensure proper ownership # The user needs write access to /app/data (home) and /app (workdir) -RUN mkdir -p /app/data && chown -R 1000:0 /app/data && chown -R 1000:0 /app +# Note: .venv is already owned by 1000:0 via COPY --chown above, so no recursive chown needed +RUN mkdir -p /app/data && chown -R 1000:0 /app/data && chown 1000:0 /app LABEL org.opencontainers.image.title=langflow-backend LABEL org.opencontainers.image.authors=['Langflow'] diff --git a/docker/build_and_push_base.Dockerfile b/docker/build_and_push_base.Dockerfile index 34f5b7cf2c..1b7c9cde05 100644 --- a/docker/build_and_push_base.Dockerfile +++ b/docker/build_and_push_base.Dockerfile @@ -61,7 +61,7 @@ WORKDIR /tmp/src/frontend # Increase memory and disable concurrent builds to avoid esbuild crashes on emulated architectures # Force esbuild to use JS implementation on emulated architectures to avoid native binary crashes RUN npm install \ - && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=12288" JOBS=1 npm run build \ + && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=4096" JOBS=1 npm run build \ && cp -r build /app/src/backend/base/langflow/frontend \ && rm -rf /tmp/src/frontend diff --git a/docker/build_and_push_ep.Dockerfile b/docker/build_and_push_ep.Dockerfile index 131eaa92b2..137be734cb 100644 --- a/docker/build_and_push_ep.Dockerfile +++ b/docker/build_and_push_ep.Dockerfile @@ -56,7 +56,7 @@ COPY src/frontend /tmp/src/frontend WORKDIR /tmp/src/frontend RUN --mount=type=cache,target=/root/.npm \ npm ci \ - && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=12288" JOBS=1 npm run build \ + && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=4096" JOBS=1 npm run build \ && cp -r build /app/src/backend/langflow/frontend \ && rm -rf /tmp/src/frontend diff --git a/docker/build_and_push_with_extras.Dockerfile b/docker/build_and_push_with_extras.Dockerfile index a23c53f4d9..c97af17249 100644 --- a/docker/build_and_push_with_extras.Dockerfile +++ b/docker/build_and_push_with_extras.Dockerfile @@ -56,7 +56,7 @@ COPY src/frontend /tmp/src/frontend WORKDIR /tmp/src/frontend RUN --mount=type=cache,target=/root/.npm \ npm ci \ - && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=12288" JOBS=1 npm run build \ + && ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=4096" JOBS=1 npm run build \ && cp -r build /app/src/backend/langflow/frontend \ && rm -rf /tmp/src/frontend diff --git a/src/backend/tests/unit/components/processing/test_data_operations_component.py b/src/backend/tests/unit/components/processing/test_data_operations_component.py index 6b83ca7a7f..a007f919f5 100644 --- a/src/backend/tests/unit/components/processing/test_data_operations_component.py +++ b/src/backend/tests/unit/components/processing/test_data_operations_component.py @@ -191,3 +191,33 @@ class TestDataOperationsComponent(ComponentTestBaseWithoutClient): with pytest.raises(ValueError, match="Select Keys operation is not supported for multiple data objects"): component.as_data() + + def test_update_build_config_clears_input_fields_when_operation_removed(self): + """Test that removing the selected operation hides all operation-specific input fields.""" + from lfx.schema.dotdict import dotdict + + component = DataOperationsComponent( + data=Data(data={"key1": "value1"}), + operations=[], + ) + # Simulate build_config after "Filter Values" was selected (operation-specific fields visible) + build_config = dotdict( + { + "operations": {"value": [], "show": True}, + "data": {"value": None, "show": True}, + "filter_key": {"value": [], "show": True}, + "operator": {"value": "equals", "show": True}, + "filter_values": {"value": {}, "show": True}, + "select_keys_input": {"value": [], "show": False}, + } + ) + result = component.update_build_config(build_config, [], "operations") + + # All operation-specific fields should be hidden when no operation is selected + assert result["filter_key"]["show"] is False + assert result["operator"]["show"] is False + assert result["filter_values"]["show"] is False + assert result["select_keys_input"]["show"] is False + # Default fields (operations, data) should remain visible + assert result["operations"]["show"] is True + assert result["data"]["show"] is True diff --git a/src/frontend/src/CustomNodes/hooks/use-handle-new-value.ts b/src/frontend/src/CustomNodes/hooks/use-handle-new-value.ts index b9dfa5b186..0832380f95 100644 --- a/src/frontend/src/CustomNodes/hooks/use-handle-new-value.ts +++ b/src/frontend/src/CustomNodes/hooks/use-handle-new-value.ts @@ -13,6 +13,20 @@ import { mutateTemplate } from "../helpers/mutate-template"; const DEBOUNCE_TIME_1_SECOND = 1000; +// Must match ALL_OPERATION_FIELDS in data_operations.py +const DATA_OPERATIONS_OPERATION_FIELDS = [ + "select_keys_input", + "filter_key", + "operator", + "filter_values", + "append_update_data", + "remove_keys_input", + "rename_keys_input", + "mapped_json_display", + "selected_key", + "query", +]; + export type handleOnNewValueType = ( changes: Partial, options?: { @@ -111,6 +125,25 @@ const useHandleOnNewValue = ({ if (value !== undefined) parameter[key] = value; }); + // When Data Operations "operations" list is cleared, optimistically hide operation-specific fields + // so the UI updates immediately without waiting for the debounced API response + if ( + name === "operations" && + Array.isArray(changes.value) && + changes.value.length === 0 && + node.display_name === "Data Operations" + ) { + for (const field of DATA_OPERATIONS_OPERATION_FIELDS) { + if ( + template[field] && + typeof template[field] === "object" && + "show" in template[field] + ) { + template[field].show = false; + } + } + } + const shouldUpdate = parameter.real_time_refresh; const setNodeClass = (newNodeClass: APIClassType) => { diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index 4672fe9132..d38a077c9d 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -98659,7 +98659,7 @@ "icon": "file-json", "legacy": false, "metadata": { - "code_hash": "f5d9680f8644", + "code_hash": "1e5bfda1706b", "dependencies": { "dependencies": [ { @@ -98763,7 +98763,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import ast\nimport json\nfrom typing import TYPE_CHECKING, Any\n\nimport jq\nfrom json_repair import repair_json\n\nfrom lfx.custom import Component\nfrom lfx.inputs import DictInput, DropdownInput, MessageTextInput, SortableListInput\nfrom lfx.io import DataInput, MultilineInput, Output\nfrom lfx.log.logger import logger\nfrom lfx.schema import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_display\n\nif TYPE_CHECKING:\n from collections.abc import Callable\n\nACTION_CONFIG = {\n \"Select Keys\": {\"is_list\": False, \"log_msg\": \"setting filter fields\"},\n \"Literal Eval\": {\"is_list\": False, \"log_msg\": \"setting evaluate fields\"},\n \"Combine\": {\"is_list\": True, \"log_msg\": \"setting combine fields\"},\n \"Filter Values\": {\"is_list\": False, \"log_msg\": \"setting filter values fields\"},\n \"Append or Update\": {\"is_list\": False, \"log_msg\": \"setting Append or Update fields\"},\n \"Remove Keys\": {\"is_list\": False, \"log_msg\": \"setting remove keys fields\"},\n \"Rename Keys\": {\"is_list\": False, \"log_msg\": \"setting rename keys fields\"},\n \"Path Selection\": {\"is_list\": False, \"log_msg\": \"setting mapped key extractor fields\"},\n \"JQ Expression\": {\"is_list\": False, \"log_msg\": \"setting parse json fields\"},\n}\nOPERATORS = {\n \"equals\": lambda a, b: str(a) == str(b),\n \"not equals\": lambda a, b: str(a) != str(b),\n \"contains\": lambda a, b: str(b) in str(a),\n \"starts with\": lambda a, b: str(a).startswith(str(b)),\n \"ends with\": lambda a, b: str(a).endswith(str(b)),\n}\n\n\nclass DataOperationsComponent(Component):\n display_name = \"Data Operations\"\n description = \"Perform various operations on a Data object.\"\n icon = \"file-json\"\n name = \"DataOperations\"\n default_keys = [\"operations\", \"data\"]\n metadata = {\n \"keywords\": [\n \"data\",\n \"operations\",\n \"filter values\",\n \"Append or Update\",\n \"remove keys\",\n \"rename keys\",\n \"select keys\",\n \"literal eval\",\n \"combine\",\n \"filter\",\n \"append\",\n \"update\",\n \"remove\",\n \"rename\",\n \"data operations\",\n \"data manipulation\",\n \"data transformation\",\n \"data filtering\",\n \"data selection\",\n \"data combination\",\n \"Parse JSON\",\n \"JSON Query\",\n \"JQ Query\",\n ],\n }\n actions_data = {\n \"Select Keys\": [\"select_keys_input\", \"operations\"],\n \"Literal Eval\": [],\n \"Combine\": [],\n \"Filter Values\": [\"filter_values\", \"operations\", \"operator\", \"filter_key\"],\n \"Append or Update\": [\"append_update_data\", \"operations\"],\n \"Remove Keys\": [\"remove_keys_input\", \"operations\"],\n \"Rename Keys\": [\"rename_keys_input\", \"operations\"],\n \"Path Selection\": [\"mapped_json_display\", \"selected_key\", \"operations\"],\n \"JQ Expression\": [\"query\", \"operations\"],\n }\n\n @staticmethod\n def extract_all_paths(obj, path=\"\"):\n paths = []\n if isinstance(obj, dict):\n for k, v in obj.items():\n new_path = f\"{path}.{k}\" if path else f\".{k}\"\n paths.append(new_path)\n paths.extend(DataOperationsComponent.extract_all_paths(v, new_path))\n elif isinstance(obj, list) and obj:\n new_path = f\"{path}[0]\"\n paths.append(new_path)\n paths.extend(DataOperationsComponent.extract_all_paths(obj[0], new_path))\n return paths\n\n @staticmethod\n def remove_keys_recursive(obj, keys_to_remove):\n if isinstance(obj, dict):\n return {\n k: DataOperationsComponent.remove_keys_recursive(v, keys_to_remove)\n for k, v in obj.items()\n if k not in keys_to_remove\n }\n if isinstance(obj, list):\n return [DataOperationsComponent.remove_keys_recursive(item, keys_to_remove) for item in obj]\n return obj\n\n @staticmethod\n def rename_keys_recursive(obj, rename_map):\n if isinstance(obj, dict):\n return {\n rename_map.get(k, k): DataOperationsComponent.rename_keys_recursive(v, rename_map)\n for k, v in obj.items()\n }\n if isinstance(obj, list):\n return [DataOperationsComponent.rename_keys_recursive(item, rename_map) for item in obj]\n return obj\n\n inputs = [\n DataInput(name=\"data\", display_name=\"Data\", info=\"Data object to filter.\", required=True, is_list=True),\n SortableListInput(\n name=\"operations\",\n display_name=\"Operations\",\n placeholder=\"Select Operation\",\n info=\"List of operations to perform on the data.\",\n options=[\n {\"name\": \"Select Keys\", \"icon\": \"lasso-select\"},\n {\"name\": \"Literal Eval\", \"icon\": \"braces\"},\n {\"name\": \"Combine\", \"icon\": \"merge\"},\n {\"name\": \"Filter Values\", \"icon\": \"filter\"},\n {\"name\": \"Append or Update\", \"icon\": \"circle-plus\"},\n {\"name\": \"Remove Keys\", \"icon\": \"eraser\"},\n {\"name\": \"Rename Keys\", \"icon\": \"pencil-line\"},\n {\"name\": \"Path Selection\", \"icon\": \"mouse-pointer\"},\n {\"name\": \"JQ Expression\", \"icon\": \"terminal\"},\n ],\n real_time_refresh=True,\n limit=1,\n ),\n # select keys inputs\n MessageTextInput(\n name=\"select_keys_input\",\n display_name=\"Select Keys\",\n info=\"List of keys to select from the data. Only top-level keys can be selected.\",\n show=False,\n is_list=True,\n ),\n # filter values inputs\n MessageTextInput(\n name=\"filter_key\",\n display_name=\"Filter Key\",\n info=(\n \"Name of the key containing the list to filter. \"\n \"It must be a top-level key in the JSON and its value must be a list.\"\n ),\n is_list=True,\n show=False,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Comparison Operator\",\n options=[\"equals\", \"not equals\", \"contains\", \"starts with\", \"ends with\"],\n info=\"The operator to apply for comparing the values.\",\n value=\"equals\",\n advanced=False,\n show=False,\n ),\n DictInput(\n name=\"filter_values\",\n display_name=\"Filter Values\",\n info=\"List of values to filter by.\",\n show=False,\n is_list=True,\n ),\n # update/ Append data inputs\n DictInput(\n name=\"append_update_data\",\n display_name=\"Append or Update\",\n info=\"Data to append or update the existing data with. Only top-level keys are checked.\",\n show=False,\n value={\"key\": \"value\"},\n is_list=True,\n ),\n # remove keys inputs\n MessageTextInput(\n name=\"remove_keys_input\",\n display_name=\"Remove Keys\",\n info=\"List of keys to remove from the data.\",\n show=False,\n is_list=True,\n ),\n # rename keys inputs\n DictInput(\n name=\"rename_keys_input\",\n display_name=\"Rename Keys\",\n info=\"List of keys to rename in the data.\",\n show=False,\n is_list=True,\n value={\"old_key\": \"new_key\"},\n ),\n MultilineInput(\n name=\"mapped_json_display\",\n display_name=\"JSON to Map\",\n info=\"Paste or preview your JSON here to explore its structure and select a path for extraction.\",\n required=False,\n refresh_button=True,\n real_time_refresh=True,\n placeholder=\"Add a JSON example.\",\n show=False,\n ),\n DropdownInput(\n name=\"selected_key\", display_name=\"Select Path\", options=[], required=False, dynamic=True, show=False\n ),\n MessageTextInput(\n name=\"query\",\n display_name=\"JQ Expression\",\n info=\"JSON Query to filter the data. Used by Parse JSON operation.\",\n placeholder=\"e.g., .properties.id\",\n show=False,\n ),\n ]\n outputs = [\n Output(display_name=\"Data\", name=\"data_output\", method=\"as_data\"),\n ]\n\n # Helper methods for data operations\n def get_data_dict(self) -> dict:\n \"\"\"Extract data dictionary from Data object.\"\"\"\n data = self.data[0] if isinstance(self.data, list) and len(self.data) == 1 else self.data\n return data.model_dump()\n\n def json_query(self) -> Data:\n import json\n\n import jq\n\n if not self.query or not self.query.strip():\n msg = \"JSON Query is required and cannot be blank.\"\n raise ValueError(msg)\n raw_data = self.get_data_dict()\n try:\n input_str = json.dumps(raw_data)\n repaired = repair_json(input_str)\n data_json = json.loads(repaired)\n jq_input = data_json[\"data\"] if isinstance(data_json, dict) and \"data\" in data_json else data_json\n results = jq.compile(self.query).input(jq_input).all()\n if not results:\n msg = \"No result from JSON query.\"\n raise ValueError(msg)\n result = results[0] if len(results) == 1 else results\n if result is None or result == \"None\":\n msg = \"JSON query returned null/None. Check if the path exists in your data.\"\n raise ValueError(msg)\n if isinstance(result, dict):\n return Data(data=result)\n return Data(data={\"result\": result})\n except (ValueError, TypeError, KeyError, json.JSONDecodeError) as e:\n logger.error(f\"JSON Query failed: {e}\")\n msg = f\"JSON Query error: {e}\"\n raise ValueError(msg) from e\n\n def get_normalized_data(self) -> dict:\n \"\"\"Get normalized data dictionary, handling the 'data' key if present.\"\"\"\n data_dict = self.get_data_dict()\n return data_dict.get(\"data\", data_dict)\n\n def data_is_list(self) -> bool:\n \"\"\"Check if data contains multiple items.\"\"\"\n return isinstance(self.data, list) and len(self.data) > 1\n\n def validate_single_data(self, operation: str) -> None:\n \"\"\"Validate that the operation is being performed on a single data object.\"\"\"\n if self.data_is_list():\n msg = f\"{operation} operation is not supported for multiple data objects.\"\n raise ValueError(msg)\n\n def operation_exception(self, operations: list[str]) -> None:\n \"\"\"Raise exception for incompatible operations.\"\"\"\n msg = f\"{operations} operations are not supported in combination with each other.\"\n raise ValueError(msg)\n\n # Data transformation operations\n def select_keys(self, *, evaluate: bool | None = None) -> Data:\n \"\"\"Select specific keys from the data dictionary.\"\"\"\n self.validate_single_data(\"Select Keys\")\n data_dict = self.get_normalized_data()\n filter_criteria: list[str] = self.select_keys_input\n\n # Filter the data\n if len(filter_criteria) == 1 and filter_criteria[0] == \"data\":\n filtered = data_dict[\"data\"]\n else:\n if not all(key in data_dict for key in filter_criteria):\n msg = f\"Select key not found in data. Available keys: {list(data_dict.keys())}\"\n raise ValueError(msg)\n filtered = {key: value for key, value in data_dict.items() if key in filter_criteria}\n\n # Create a new Data object with the filtered data\n if evaluate:\n filtered = self.recursive_eval(filtered)\n\n # Return a new Data object with the filtered data directly in the data attribute\n return Data(data=filtered)\n\n def remove_keys(self) -> Data:\n \"\"\"Remove specified keys from the data dictionary, recursively.\"\"\"\n self.validate_single_data(\"Remove Keys\")\n data_dict = self.get_normalized_data()\n remove_keys_input: list[str] = self.remove_keys_input\n\n filtered = DataOperationsComponent.remove_keys_recursive(data_dict, set(remove_keys_input))\n return Data(data=filtered)\n\n def rename_keys(self) -> Data:\n \"\"\"Rename keys in the data dictionary, recursively.\"\"\"\n self.validate_single_data(\"Rename Keys\")\n data_dict = self.get_normalized_data()\n rename_keys_input: dict[str, str] = self.rename_keys_input\n\n renamed = DataOperationsComponent.rename_keys_recursive(data_dict, rename_keys_input)\n return Data(data=renamed)\n\n def recursive_eval(self, data: Any) -> Any:\n \"\"\"Recursively evaluate string values in a dictionary or list.\n\n If the value is a string that can be evaluated, it will be evaluated.\n Otherwise, the original value is returned.\n \"\"\"\n if isinstance(data, dict):\n return {k: self.recursive_eval(v) for k, v in data.items()}\n if isinstance(data, list):\n return [self.recursive_eval(item) for item in data]\n if isinstance(data, str):\n try:\n # Only attempt to evaluate strings that look like Python literals\n if (\n data.strip().startswith((\"{\", \"[\", \"(\", \"'\", '\"'))\n or data.strip().lower() in (\"true\", \"false\", \"none\")\n or data.strip().replace(\".\", \"\").isdigit()\n ):\n return ast.literal_eval(data)\n # return data\n except (ValueError, SyntaxError, TypeError, MemoryError):\n # If evaluation fails for any reason, return the original string\n return data\n else:\n return data\n return data\n\n def evaluate_data(self) -> Data:\n \"\"\"Evaluate string values in the data dictionary.\"\"\"\n self.validate_single_data(\"Literal Eval\")\n logger.info(\"evaluating data\")\n return Data(**self.recursive_eval(self.get_data_dict()))\n\n def combine_data(self, *, evaluate: bool | None = None) -> Data:\n \"\"\"Combine multiple data objects into one.\"\"\"\n logger.info(\"combining data\")\n if not self.data_is_list():\n return self.data[0] if self.data else Data(data={})\n\n if len(self.data) == 1:\n msg = \"Combine operation requires multiple data inputs.\"\n raise ValueError(msg)\n\n data_dicts = [data.model_dump().get(\"data\", data.model_dump()) for data in self.data]\n combined_data = {}\n\n for data_dict in data_dicts:\n for key, value in data_dict.items():\n if key not in combined_data:\n combined_data[key] = value\n elif isinstance(combined_data[key], list):\n if isinstance(value, list):\n combined_data[key].extend(value)\n else:\n combined_data[key].append(value)\n else:\n # If current value is not a list, convert it to list and add new value\n combined_data[key] = (\n [combined_data[key], value] if not isinstance(value, list) else [combined_data[key], *value]\n )\n\n if evaluate:\n combined_data = self.recursive_eval(combined_data)\n\n return Data(**combined_data)\n\n def filter_data(self, input_data: list[dict[str, Any]], filter_key: str, filter_value: str, operator: str) -> list:\n \"\"\"Filter list data based on key, value, and operator.\"\"\"\n # Validate inputs\n if not input_data:\n self.status = \"Input data is empty.\"\n return []\n\n if not filter_key or not filter_value:\n self.status = \"Filter key or value is missing.\"\n return input_data\n\n # Filter the data\n filtered_data = []\n for item in input_data:\n if isinstance(item, dict) and filter_key in item:\n if self.compare_values(item[filter_key], filter_value, operator):\n filtered_data.append(item)\n else:\n self.status = f\"Warning: Some items don't have the key '{filter_key}' or are not dictionaries.\"\n\n return filtered_data\n\n def compare_values(self, item_value: Any, filter_value: str, operator: str) -> bool:\n comparison_func = OPERATORS.get(operator)\n if comparison_func:\n return comparison_func(item_value, filter_value)\n return False\n\n def multi_filter_data(self) -> Data:\n \"\"\"Apply multiple filters to the data.\"\"\"\n self.validate_single_data(\"Filter Values\")\n data_filtered = self.get_normalized_data()\n\n for filter_key in self.filter_key:\n if filter_key not in data_filtered:\n msg = f\"Filter key '{filter_key}' not found in data. Available keys: {list(data_filtered.keys())}\"\n raise ValueError(msg)\n\n if isinstance(data_filtered[filter_key], list):\n for filter_data in self.filter_values:\n filter_value = self.filter_values.get(filter_data)\n if filter_value is not None:\n data_filtered[filter_key] = self.filter_data(\n input_data=data_filtered[filter_key],\n filter_key=filter_data,\n filter_value=filter_value,\n operator=self.operator,\n )\n else:\n msg = f\"Filter key '{filter_key}' is not a list.\"\n raise TypeError(msg)\n\n return Data(**data_filtered)\n\n def append_update(self) -> Data:\n \"\"\"Append or Update with new key-value pairs.\"\"\"\n self.validate_single_data(\"Append or Update\")\n data_filtered = self.get_normalized_data()\n\n for key, value in self.append_update_data.items():\n data_filtered[key] = value\n\n return Data(**data_filtered)\n\n # Configuration and execution methods\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n if field_name == \"operations\":\n build_config[\"operations\"][\"value\"] = field_value\n selected_actions = [action[\"name\"] for action in field_value]\n if len(selected_actions) == 1 and selected_actions[0] in ACTION_CONFIG:\n action = selected_actions[0]\n config = ACTION_CONFIG[action]\n build_config[\"data\"][\"is_list\"] = config[\"is_list\"]\n logger.info(config[\"log_msg\"])\n return set_current_fields(\n build_config=build_config,\n action_fields=self.actions_data,\n selected_action=action,\n default_fields=[\"operations\", \"data\"],\n func=set_field_display,\n )\n\n if field_name == \"mapped_json_display\":\n try:\n parsed_json = json.loads(field_value)\n keys = DataOperationsComponent.extract_all_paths(parsed_json)\n build_config[\"selected_key\"][\"options\"] = keys\n build_config[\"selected_key\"][\"show\"] = True\n except (json.JSONDecodeError, TypeError, ValueError) as e:\n logger.error(f\"Error parsing mapped JSON: {e}\")\n build_config[\"selected_key\"][\"show\"] = False\n\n return build_config\n\n def json_path(self) -> Data:\n try:\n if not self.data or not self.selected_key:\n msg = \"Missing input data or selected key.\"\n raise ValueError(msg)\n input_payload = self.data[0].data if isinstance(self.data, list) else self.data.data\n compiled = jq.compile(self.selected_key)\n result = compiled.input(input_payload).first()\n if isinstance(result, dict):\n return Data(data=result)\n return Data(data={\"result\": result})\n except (ValueError, TypeError, KeyError) as e:\n self.status = f\"Error: {e!s}\"\n self.log(self.status)\n return Data(data={\"error\": str(e)})\n\n def as_data(self) -> Data:\n if not hasattr(self, \"operations\") or not self.operations:\n return Data(data={})\n\n selected_actions = [action[\"name\"] for action in self.operations]\n logger.info(f\"selected_actions: {selected_actions}\")\n if len(selected_actions) != 1:\n return Data(data={})\n\n action_map: dict[str, Callable[[], Data]] = {\n \"Select Keys\": self.select_keys,\n \"Literal Eval\": self.evaluate_data,\n \"Combine\": self.combine_data,\n \"Filter Values\": self.multi_filter_data,\n \"Append or Update\": self.append_update,\n \"Remove Keys\": self.remove_keys,\n \"Rename Keys\": self.rename_keys,\n \"Path Selection\": self.json_path,\n \"JQ Expression\": self.json_query,\n }\n handler: Callable[[], Data] | None = action_map.get(selected_actions[0])\n if handler:\n try:\n return handler()\n except Exception as e:\n logger.error(f\"Error executing {selected_actions[0]}: {e!s}\")\n raise\n return Data(data={})\n" + "value": "import ast\nimport json\nfrom typing import TYPE_CHECKING, Any\n\nimport jq\nfrom json_repair import repair_json\n\nfrom lfx.custom import Component\nfrom lfx.inputs import DictInput, DropdownInput, MessageTextInput, SortableListInput\nfrom lfx.io import DataInput, MultilineInput, Output\nfrom lfx.log.logger import logger\nfrom lfx.schema import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_display\n\nif TYPE_CHECKING:\n from collections.abc import Callable\n\nACTION_CONFIG = {\n \"Select Keys\": {\"is_list\": False, \"log_msg\": \"setting filter fields\"},\n \"Literal Eval\": {\"is_list\": False, \"log_msg\": \"setting evaluate fields\"},\n \"Combine\": {\"is_list\": True, \"log_msg\": \"setting combine fields\"},\n \"Filter Values\": {\"is_list\": False, \"log_msg\": \"setting filter values fields\"},\n \"Append or Update\": {\"is_list\": False, \"log_msg\": \"setting Append or Update fields\"},\n \"Remove Keys\": {\"is_list\": False, \"log_msg\": \"setting remove keys fields\"},\n \"Rename Keys\": {\"is_list\": False, \"log_msg\": \"setting rename keys fields\"},\n \"Path Selection\": {\"is_list\": False, \"log_msg\": \"setting mapped key extractor fields\"},\n \"JQ Expression\": {\"is_list\": False, \"log_msg\": \"setting parse json fields\"},\n}\nOPERATORS = {\n \"equals\": lambda a, b: str(a) == str(b),\n \"not equals\": lambda a, b: str(a) != str(b),\n \"contains\": lambda a, b: str(b) in str(a),\n \"starts with\": lambda a, b: str(a).startswith(str(b)),\n \"ends with\": lambda a, b: str(a).endswith(str(b)),\n}\n\n\nclass DataOperationsComponent(Component):\n display_name = \"Data Operations\"\n description = \"Perform various operations on a Data object.\"\n icon = \"file-json\"\n name = \"DataOperations\"\n default_keys = [\"operations\", \"data\"]\n metadata = {\n \"keywords\": [\n \"data\",\n \"operations\",\n \"filter values\",\n \"Append or Update\",\n \"remove keys\",\n \"rename keys\",\n \"select keys\",\n \"literal eval\",\n \"combine\",\n \"filter\",\n \"append\",\n \"update\",\n \"remove\",\n \"rename\",\n \"data operations\",\n \"data manipulation\",\n \"data transformation\",\n \"data filtering\",\n \"data selection\",\n \"data combination\",\n \"Parse JSON\",\n \"JSON Query\",\n \"JQ Query\",\n ],\n }\n actions_data = {\n \"Select Keys\": [\"select_keys_input\", \"operations\"],\n \"Literal Eval\": [],\n \"Combine\": [],\n \"Filter Values\": [\"filter_values\", \"operations\", \"operator\", \"filter_key\"],\n \"Append or Update\": [\"append_update_data\", \"operations\"],\n \"Remove Keys\": [\"remove_keys_input\", \"operations\"],\n \"Rename Keys\": [\"rename_keys_input\", \"operations\"],\n \"Path Selection\": [\"mapped_json_display\", \"selected_key\", \"operations\"],\n \"JQ Expression\": [\"query\", \"operations\"],\n }\n\n # All operation-specific input fields (used to hide and reset when no operation selected).\n ALL_OPERATION_FIELDS = [\n \"select_keys_input\",\n \"filter_key\",\n \"operator\",\n \"filter_values\",\n \"append_update_data\",\n \"remove_keys_input\",\n \"rename_keys_input\",\n \"mapped_json_display\",\n \"selected_key\",\n \"query\",\n ]\n\n @staticmethod\n def extract_all_paths(obj, path=\"\"):\n paths = []\n if isinstance(obj, dict):\n for k, v in obj.items():\n new_path = f\"{path}.{k}\" if path else f\".{k}\"\n paths.append(new_path)\n paths.extend(DataOperationsComponent.extract_all_paths(v, new_path))\n elif isinstance(obj, list) and obj:\n new_path = f\"{path}[0]\"\n paths.append(new_path)\n paths.extend(DataOperationsComponent.extract_all_paths(obj[0], new_path))\n return paths\n\n @staticmethod\n def remove_keys_recursive(obj, keys_to_remove):\n if isinstance(obj, dict):\n return {\n k: DataOperationsComponent.remove_keys_recursive(v, keys_to_remove)\n for k, v in obj.items()\n if k not in keys_to_remove\n }\n if isinstance(obj, list):\n return [DataOperationsComponent.remove_keys_recursive(item, keys_to_remove) for item in obj]\n return obj\n\n @staticmethod\n def rename_keys_recursive(obj, rename_map):\n if isinstance(obj, dict):\n return {\n rename_map.get(k, k): DataOperationsComponent.rename_keys_recursive(v, rename_map)\n for k, v in obj.items()\n }\n if isinstance(obj, list):\n return [DataOperationsComponent.rename_keys_recursive(item, rename_map) for item in obj]\n return obj\n\n inputs = [\n DataInput(name=\"data\", display_name=\"Data\", info=\"Data object to filter.\", required=True, is_list=True),\n SortableListInput(\n name=\"operations\",\n display_name=\"Operations\",\n placeholder=\"Select Operation\",\n info=\"List of operations to perform on the data.\",\n options=[\n {\"name\": \"Select Keys\", \"icon\": \"lasso-select\"},\n {\"name\": \"Literal Eval\", \"icon\": \"braces\"},\n {\"name\": \"Combine\", \"icon\": \"merge\"},\n {\"name\": \"Filter Values\", \"icon\": \"filter\"},\n {\"name\": \"Append or Update\", \"icon\": \"circle-plus\"},\n {\"name\": \"Remove Keys\", \"icon\": \"eraser\"},\n {\"name\": \"Rename Keys\", \"icon\": \"pencil-line\"},\n {\"name\": \"Path Selection\", \"icon\": \"mouse-pointer\"},\n {\"name\": \"JQ Expression\", \"icon\": \"terminal\"},\n ],\n real_time_refresh=True,\n limit=1,\n ),\n # select keys inputs\n MessageTextInput(\n name=\"select_keys_input\",\n display_name=\"Select Keys\",\n info=\"List of keys to select from the data. Only top-level keys can be selected.\",\n show=False,\n is_list=True,\n value=[],\n ),\n # filter values inputs\n MessageTextInput(\n name=\"filter_key\",\n display_name=\"Filter Key\",\n info=(\n \"Name of the key containing the list to filter. \"\n \"It must be a top-level key in the JSON and its value must be a list.\"\n ),\n is_list=True,\n show=False,\n value=[],\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Comparison Operator\",\n options=[\"equals\", \"not equals\", \"contains\", \"starts with\", \"ends with\"],\n info=\"The operator to apply for comparing the values.\",\n value=\"equals\",\n advanced=False,\n show=False,\n ),\n DictInput(\n name=\"filter_values\",\n display_name=\"Filter Values\",\n info=\"List of values to filter by.\",\n show=False,\n is_list=True,\n value={},\n ),\n # update/ Append data inputs\n DictInput(\n name=\"append_update_data\",\n display_name=\"Append or Update\",\n info=\"Data to append or update the existing data with. Only top-level keys are checked.\",\n show=False,\n value={\"key\": \"value\"},\n is_list=True,\n ),\n # remove keys inputs\n MessageTextInput(\n name=\"remove_keys_input\",\n display_name=\"Remove Keys\",\n info=\"List of keys to remove from the data.\",\n show=False,\n is_list=True,\n value=[],\n ),\n # rename keys inputs\n DictInput(\n name=\"rename_keys_input\",\n display_name=\"Rename Keys\",\n info=\"List of keys to rename in the data.\",\n show=False,\n is_list=True,\n value={\"old_key\": \"new_key\"},\n ),\n MultilineInput(\n name=\"mapped_json_display\",\n display_name=\"JSON to Map\",\n info=\"Paste or preview your JSON here to explore its structure and select a path for extraction.\",\n required=False,\n refresh_button=True,\n real_time_refresh=True,\n placeholder=\"Add a JSON example.\",\n show=False,\n ),\n DropdownInput(\n name=\"selected_key\",\n display_name=\"Select Path\",\n options=[],\n required=False,\n dynamic=True,\n show=False,\n value=None,\n ),\n MessageTextInput(\n name=\"query\",\n display_name=\"JQ Expression\",\n info=\"JSON Query to filter the data. Used by Parse JSON operation.\",\n placeholder=\"e.g., .properties.id\",\n show=False,\n ),\n ]\n\n # Default values for operation fields when clearing (match input definitions)\n OPERATION_FIELD_DEFAULTS: dict[str, Any] = {\n \"select_keys_input\": [],\n \"filter_key\": [],\n \"operator\": \"equals\",\n \"filter_values\": {},\n \"append_update_data\": {\"key\": \"value\"},\n \"remove_keys_input\": [],\n \"rename_keys_input\": {\"old_key\": \"new_key\"},\n \"mapped_json_display\": \"\",\n \"selected_key\": None,\n \"query\": \"\",\n }\n\n outputs = [\n Output(display_name=\"Data\", name=\"data_output\", method=\"as_data\"),\n ]\n\n # Helper methods for data operations\n def get_data_dict(self) -> dict:\n \"\"\"Extract data dictionary from Data object.\"\"\"\n data = self.data[0] if isinstance(self.data, list) and len(self.data) == 1 else self.data\n return data.model_dump()\n\n def json_query(self) -> Data:\n import json\n\n import jq\n\n if not self.query or not self.query.strip():\n msg = \"JSON Query is required and cannot be blank.\"\n raise ValueError(msg)\n raw_data = self.get_data_dict()\n try:\n input_str = json.dumps(raw_data)\n repaired = repair_json(input_str)\n data_json = json.loads(repaired)\n jq_input = data_json[\"data\"] if isinstance(data_json, dict) and \"data\" in data_json else data_json\n results = jq.compile(self.query).input(jq_input).all()\n if not results:\n msg = \"No result from JSON query.\"\n raise ValueError(msg)\n result = results[0] if len(results) == 1 else results\n if result is None or result == \"None\":\n msg = \"JSON query returned null/None. Check if the path exists in your data.\"\n raise ValueError(msg)\n if isinstance(result, dict):\n return Data(data=result)\n return Data(data={\"result\": result})\n except (ValueError, TypeError, KeyError, json.JSONDecodeError) as e:\n logger.error(f\"JSON Query failed: {e}\")\n msg = f\"JSON Query error: {e}\"\n raise ValueError(msg) from e\n\n def get_normalized_data(self) -> dict:\n \"\"\"Get normalized data dictionary, handling the 'data' key if present.\"\"\"\n data_dict = self.get_data_dict()\n return data_dict.get(\"data\", data_dict)\n\n def data_is_list(self) -> bool:\n \"\"\"Check if data contains multiple items.\"\"\"\n return isinstance(self.data, list) and len(self.data) > 1\n\n def validate_single_data(self, operation: str) -> None:\n \"\"\"Validate that the operation is being performed on a single data object.\"\"\"\n if self.data_is_list():\n msg = f\"{operation} operation is not supported for multiple data objects.\"\n raise ValueError(msg)\n\n def operation_exception(self, operations: list[str]) -> None:\n \"\"\"Raise exception for incompatible operations.\"\"\"\n msg = f\"{operations} operations are not supported in combination with each other.\"\n raise ValueError(msg)\n\n # Data transformation operations\n def select_keys(self, *, evaluate: bool | None = None) -> Data:\n \"\"\"Select specific keys from the data dictionary.\"\"\"\n self.validate_single_data(\"Select Keys\")\n data_dict = self.get_normalized_data()\n filter_criteria: list[str] = self.select_keys_input\n\n # Filter the data\n if len(filter_criteria) == 1 and filter_criteria[0] == \"data\":\n filtered = data_dict[\"data\"]\n else:\n if not all(key in data_dict for key in filter_criteria):\n msg = f\"Select key not found in data. Available keys: {list(data_dict.keys())}\"\n raise ValueError(msg)\n filtered = {key: value for key, value in data_dict.items() if key in filter_criteria}\n\n # Create a new Data object with the filtered data\n if evaluate:\n filtered = self.recursive_eval(filtered)\n\n # Return a new Data object with the filtered data directly in the data attribute\n return Data(data=filtered)\n\n def remove_keys(self) -> Data:\n \"\"\"Remove specified keys from the data dictionary, recursively.\"\"\"\n self.validate_single_data(\"Remove Keys\")\n data_dict = self.get_normalized_data()\n remove_keys_input: list[str] = self.remove_keys_input\n\n filtered = DataOperationsComponent.remove_keys_recursive(data_dict, set(remove_keys_input))\n return Data(data=filtered)\n\n def rename_keys(self) -> Data:\n \"\"\"Rename keys in the data dictionary, recursively.\"\"\"\n self.validate_single_data(\"Rename Keys\")\n data_dict = self.get_normalized_data()\n rename_keys_input: dict[str, str] = self.rename_keys_input\n\n renamed = DataOperationsComponent.rename_keys_recursive(data_dict, rename_keys_input)\n return Data(data=renamed)\n\n def recursive_eval(self, data: Any) -> Any:\n \"\"\"Recursively evaluate string values in a dictionary or list.\n\n If the value is a string that can be evaluated, it will be evaluated.\n Otherwise, the original value is returned.\n \"\"\"\n if isinstance(data, dict):\n return {k: self.recursive_eval(v) for k, v in data.items()}\n if isinstance(data, list):\n return [self.recursive_eval(item) for item in data]\n if isinstance(data, str):\n try:\n # Only attempt to evaluate strings that look like Python literals\n if (\n data.strip().startswith((\"{\", \"[\", \"(\", \"'\", '\"'))\n or data.strip().lower() in (\"true\", \"false\", \"none\")\n or data.strip().replace(\".\", \"\").isdigit()\n ):\n return ast.literal_eval(data)\n # return data\n except (ValueError, SyntaxError, TypeError, MemoryError):\n # If evaluation fails for any reason, return the original string\n return data\n else:\n return data\n return data\n\n def evaluate_data(self) -> Data:\n \"\"\"Evaluate string values in the data dictionary.\"\"\"\n self.validate_single_data(\"Literal Eval\")\n logger.info(\"evaluating data\")\n return Data(**self.recursive_eval(self.get_data_dict()))\n\n def combine_data(self, *, evaluate: bool | None = None) -> Data:\n \"\"\"Combine multiple data objects into one.\"\"\"\n logger.info(\"combining data\")\n if not self.data_is_list():\n return self.data[0] if self.data else Data(data={})\n\n if len(self.data) == 1:\n msg = \"Combine operation requires multiple data inputs.\"\n raise ValueError(msg)\n\n data_dicts = [data.model_dump().get(\"data\", data.model_dump()) for data in self.data]\n combined_data = {}\n\n for data_dict in data_dicts:\n for key, value in data_dict.items():\n if key not in combined_data:\n combined_data[key] = value\n elif isinstance(combined_data[key], list):\n if isinstance(value, list):\n combined_data[key].extend(value)\n else:\n combined_data[key].append(value)\n else:\n # If current value is not a list, convert it to list and add new value\n combined_data[key] = (\n [combined_data[key], value] if not isinstance(value, list) else [combined_data[key], *value]\n )\n\n if evaluate:\n combined_data = self.recursive_eval(combined_data)\n\n return Data(**combined_data)\n\n def filter_data(self, input_data: list[dict[str, Any]], filter_key: str, filter_value: str, operator: str) -> list:\n \"\"\"Filter list data based on key, value, and operator.\"\"\"\n # Validate inputs\n if not input_data:\n self.status = \"Input data is empty.\"\n return []\n\n if not filter_key or not filter_value:\n self.status = \"Filter key or value is missing.\"\n return input_data\n\n # Filter the data\n filtered_data = []\n for item in input_data:\n if isinstance(item, dict) and filter_key in item:\n if self.compare_values(item[filter_key], filter_value, operator):\n filtered_data.append(item)\n else:\n self.status = f\"Warning: Some items don't have the key '{filter_key}' or are not dictionaries.\"\n\n return filtered_data\n\n def compare_values(self, item_value: Any, filter_value: str, operator: str) -> bool:\n comparison_func = OPERATORS.get(operator)\n if comparison_func:\n return comparison_func(item_value, filter_value)\n return False\n\n def multi_filter_data(self) -> Data:\n \"\"\"Apply multiple filters to the data.\"\"\"\n self.validate_single_data(\"Filter Values\")\n data_filtered = self.get_normalized_data()\n\n for filter_key in self.filter_key:\n if filter_key not in data_filtered:\n msg = f\"Filter key '{filter_key}' not found in data. Available keys: {list(data_filtered.keys())}\"\n raise ValueError(msg)\n\n if isinstance(data_filtered[filter_key], list):\n for filter_data in self.filter_values:\n filter_value = self.filter_values.get(filter_data)\n if filter_value is not None:\n data_filtered[filter_key] = self.filter_data(\n input_data=data_filtered[filter_key],\n filter_key=filter_data,\n filter_value=filter_value,\n operator=self.operator,\n )\n else:\n msg = f\"Filter key '{filter_key}' is not a list.\"\n raise TypeError(msg)\n\n return Data(**data_filtered)\n\n def append_update(self) -> Data:\n \"\"\"Append or Update with new key-value pairs.\"\"\"\n self.validate_single_data(\"Append or Update\")\n data_filtered = self.get_normalized_data()\n\n for key, value in self.append_update_data.items():\n data_filtered[key] = value\n\n return Data(**data_filtered)\n\n # Configuration and execution methods\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n if field_name == \"operations\":\n build_config[\"operations\"][\"value\"] = field_value\n # Mirror Text Operations: first hide all operation-specific fields and clear their values\n for field in self.ALL_OPERATION_FIELDS:\n if field in build_config:\n build_config[field][\"show\"] = False\n if field in self.OPERATION_FIELD_DEFAULTS:\n build_config[field][\"value\"] = self.OPERATION_FIELD_DEFAULTS[field]\n\n selected_actions = [\n action[\"name\"] for action in (field_value or []) if isinstance(action, dict) and \"name\" in action\n ]\n if len(selected_actions) == 1 and selected_actions[0] in ACTION_CONFIG:\n action = selected_actions[0]\n config = ACTION_CONFIG[action]\n build_config[\"data\"][\"is_list\"] = config[\"is_list\"]\n logger.info(config[\"log_msg\"])\n return set_current_fields(\n build_config=build_config,\n action_fields=self.actions_data,\n selected_action=action,\n default_fields=[\"operations\", \"data\"],\n func=set_field_display,\n )\n return build_config\n\n if field_name == \"mapped_json_display\":\n try:\n parsed_json = json.loads(field_value)\n keys = DataOperationsComponent.extract_all_paths(parsed_json)\n build_config[\"selected_key\"][\"options\"] = keys\n build_config[\"selected_key\"][\"show\"] = True\n except (json.JSONDecodeError, TypeError, ValueError) as e:\n logger.error(f\"Error parsing mapped JSON: {e}\")\n build_config[\"selected_key\"][\"show\"] = False\n\n return build_config\n\n def json_path(self) -> Data:\n try:\n if not self.data or not self.selected_key:\n msg = \"Missing input data or selected key.\"\n raise ValueError(msg)\n input_payload = self.data[0].data if isinstance(self.data, list) else self.data.data\n compiled = jq.compile(self.selected_key)\n result = compiled.input(input_payload).first()\n if isinstance(result, dict):\n return Data(data=result)\n return Data(data={\"result\": result})\n except (ValueError, TypeError, KeyError) as e:\n self.status = f\"Error: {e!s}\"\n self.log(self.status)\n return Data(data={\"error\": str(e)})\n\n def as_data(self) -> Data:\n if not hasattr(self, \"operations\") or not self.operations:\n return Data(data={})\n\n selected_actions = [action[\"name\"] for action in self.operations]\n logger.info(f\"selected_actions: {selected_actions}\")\n if len(selected_actions) != 1:\n return Data(data={})\n\n action_map: dict[str, Callable[[], Data]] = {\n \"Select Keys\": self.select_keys,\n \"Literal Eval\": self.evaluate_data,\n \"Combine\": self.combine_data,\n \"Filter Values\": self.multi_filter_data,\n \"Append or Update\": self.append_update,\n \"Remove Keys\": self.remove_keys,\n \"Rename Keys\": self.rename_keys,\n \"Path Selection\": self.json_path,\n \"JQ Expression\": self.json_query,\n }\n handler: Callable[[], Data] | None = action_map.get(selected_actions[0])\n if handler:\n try:\n return handler()\n except Exception as e:\n logger.error(f\"Error executing {selected_actions[0]}: {e!s}\")\n raise\n return Data(data={})\n" }, "data": { "_input_type": "DataInput", @@ -98812,7 +98812,7 @@ "trace_as_metadata": true, "track_in_telemetry": false, "type": "str", - "value": "" + "value": [] }, "filter_values": { "_input_type": "DictInput", @@ -99002,7 +99002,7 @@ "trace_as_metadata": true, "track_in_telemetry": false, "type": "str", - "value": "" + "value": [] }, "rename_keys_input": { "_input_type": "DictInput", @@ -99049,7 +99049,7 @@ "trace_as_metadata": true, "track_in_telemetry": false, "type": "str", - "value": "" + "value": [] }, "selected_key": { "_input_type": "DropdownInput", @@ -99072,8 +99072,7 @@ "tool_mode": false, "trace_as_metadata": true, "track_in_telemetry": true, - "type": "str", - "value": "" + "type": "str" } }, "tool_mode": false @@ -116364,6 +116363,6 @@ "num_components": 356, "num_modules": 95 }, - "sha256": "57d1dbbe87f888cab6a1195dd167160350acdbfdc852eba7629f11b9904f9794", + "sha256": "376ecb22e4585d65b0d4864799489f7fe46f92f7cf3e70fa30623372fa7ed1ef", "version": "0.3.0" } \ No newline at end of file diff --git a/src/lfx/src/lfx/_assets/stable_hash_history.json b/src/lfx/src/lfx/_assets/stable_hash_history.json index 39438fd99e..e9243e4433 100644 --- a/src/lfx/src/lfx/_assets/stable_hash_history.json +++ b/src/lfx/src/lfx/_assets/stable_hash_history.json @@ -1371,7 +1371,7 @@ }, "DataOperations": { "versions": { - "0.3.0": "f5d9680f8644" + "0.3.0": "1e5bfda1706b" } }, "DataToDataFrame": { diff --git a/src/lfx/src/lfx/components/processing/data_operations.py b/src/lfx/src/lfx/components/processing/data_operations.py index c468dcc1c7..9c6b9fdd76 100644 --- a/src/lfx/src/lfx/components/processing/data_operations.py +++ b/src/lfx/src/lfx/components/processing/data_operations.py @@ -81,6 +81,20 @@ class DataOperationsComponent(Component): "JQ Expression": ["query", "operations"], } + # All operation-specific input fields (used to hide and reset when no operation selected). + ALL_OPERATION_FIELDS = [ + "select_keys_input", + "filter_key", + "operator", + "filter_values", + "append_update_data", + "remove_keys_input", + "rename_keys_input", + "mapped_json_display", + "selected_key", + "query", + ] + @staticmethod def extract_all_paths(obj, path=""): paths = [] @@ -146,6 +160,7 @@ class DataOperationsComponent(Component): info="List of keys to select from the data. Only top-level keys can be selected.", show=False, is_list=True, + value=[], ), # filter values inputs MessageTextInput( @@ -157,6 +172,7 @@ class DataOperationsComponent(Component): ), is_list=True, show=False, + value=[], ), DropdownInput( name="operator", @@ -173,6 +189,7 @@ class DataOperationsComponent(Component): info="List of values to filter by.", show=False, is_list=True, + value={}, ), # update/ Append data inputs DictInput( @@ -190,6 +207,7 @@ class DataOperationsComponent(Component): info="List of keys to remove from the data.", show=False, is_list=True, + value=[], ), # rename keys inputs DictInput( @@ -211,7 +229,13 @@ class DataOperationsComponent(Component): show=False, ), DropdownInput( - name="selected_key", display_name="Select Path", options=[], required=False, dynamic=True, show=False + name="selected_key", + display_name="Select Path", + options=[], + required=False, + dynamic=True, + show=False, + value=None, ), MessageTextInput( name="query", @@ -221,6 +245,21 @@ class DataOperationsComponent(Component): show=False, ), ] + + # Default values for operation fields when clearing (match input definitions) + OPERATION_FIELD_DEFAULTS: dict[str, Any] = { + "select_keys_input": [], + "filter_key": [], + "operator": "equals", + "filter_values": {}, + "append_update_data": {"key": "value"}, + "remove_keys_input": [], + "rename_keys_input": {"old_key": "new_key"}, + "mapped_json_display": "", + "selected_key": None, + "query": "", + } + outputs = [ Output(display_name="Data", name="data_output", method="as_data"), ] @@ -456,7 +495,16 @@ class DataOperationsComponent(Component): def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict: if field_name == "operations": build_config["operations"]["value"] = field_value - selected_actions = [action["name"] for action in field_value] + # Mirror Text Operations: first hide all operation-specific fields and clear their values + for field in self.ALL_OPERATION_FIELDS: + if field in build_config: + build_config[field]["show"] = False + if field in self.OPERATION_FIELD_DEFAULTS: + build_config[field]["value"] = self.OPERATION_FIELD_DEFAULTS[field] + + selected_actions = [ + action["name"] for action in (field_value or []) if isinstance(action, dict) and "name" in action + ] if len(selected_actions) == 1 and selected_actions[0] in ACTION_CONFIG: action = selected_actions[0] config = ACTION_CONFIG[action] @@ -469,6 +517,7 @@ class DataOperationsComponent(Component): default_fields=["operations", "data"], func=set_field_display, ) + return build_config if field_name == "mapped_json_display": try: