Compare commits

...

14 Commits

Author SHA1 Message Date
Zamil Majdy
7c90b16209 Merge branch 'dev' into feat/ai-generated-mode 2026-01-12 15:41:14 -06:00
Zamil Majdy
3ec2f1953f refactor(backend): make is_ai_generated parameter mandatory in GraphSettings.from_graph()
- Removed default value from is_ai_generated parameter
- Forces all callers to explicitly specify whether graph is AI-generated
- Explicitly set is_ai_generated=False for store installations (curated user-published agents)
- Makes the code more explicit and prevents accidental defaults
2026-01-12 15:35:24 -06:00
Zamil Majdy
8d3dfe2cf8 fix(backend): preserve is_ai_generated_graph flag on version update
When updating a library agent to a new version, the is_ai_generated_graph
flag was being reset to False (the default), disabling human review for
AI-generated graphs on subsequent runs.

Fix: Pass the existing is_ai_generated_graph value from library.settings
to GraphSettings.from_graph() to preserve the flag across version updates.

Uses from_graph() to reduce duplication while preserving all settings.
2026-01-12 15:22:13 -06:00
Zamil Majdy
7133580d43 feat(backend): add is_ai_generated_graph setting and cleanup human_in_the_loop_safe_mode duplication
- Add is_ai_generated_graph field to GraphSettings with default false
- Add GraphSettings.from_graph() class method for initialization
- Add is_ai_generated_graph to ExecutionContext
- Update block review logic: required_human_review blocks only pause for review if safe_mode=True AND is_ai_generated_graph=True
- HITL blocks continue to respect only safe_mode (unchanged)
- Expose is_ai_generated in CreateGraph API model and create_new_graph endpoint

Cleanup:
- Remove redundant update_library_agent_settings wrapper function (25 lines)
- Inline human_in_the_loop_safe_mode initialization logic using GraphSettings.from_graph()
- Remove unnecessary branching and comments
- Total: 68 lines removed, 34 lines added

Tests: All 33 tests passing (26 API + 7 executor)
2026-01-12 15:12:41 -06:00
Toran Bruce Richards
db8b43bb3d feat(blocks): Add WordPress Get All Posts block and Publish Post draft toggle (#11003)
**Implements issue #11002**

This PR adds WordPress post management functionality and improves error
handling in DataForSEO blocks.

### Changes 🏗️

1. **New WordPress Blocks:**
- Added `WordPressGetAllPostsBlock` - Fetches posts from WordPress sites
with filtering and pagination support
- Enhanced `WordPressCreatePostBlock` with `publish_as_draft` toggle to
control post publication status

2. **WordPress API Enhancements:**
- Added `get_posts()` function in `_api.py` to retrieve posts with
filtering by status
- Added `PostsResponse` model for handling WordPress posts list API
responses
- Support for pagination with `number` and `offset` parameters (max 100
posts per request)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  
  **Test Plan:**
- [x] Test `WordPressGetAllPostsBlock` with valid WordPress credentials
  - [x] Verify filtering posts by status (publish, draft, pending, etc.)
  - [x] Test pagination with different number and offset values
- [x] Test `WordPressCreatePostBlock` with publish_as_draft=True to
create draft posts
- [x] Test `WordPressCreatePostBlock` with publish_as_draft=False to
publish posts publicly

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

**Note:** No configuration changes were required for this PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a WordPress “Get All Posts” block to fetch posts with optional
status filtering and pagination; returns total found and post details.
* **Enhancements**
* WordPress “Create Post” block now supports a “Publish as draft”
option, allowing posts to be created as drafts or published immediately.
* WordPress blocks are now surfaced consistently in the block catalog
for easier use.
* **Error Handling**
* Clearer error messages when fetching posts fails, aiding
troubleshooting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces WordPress post listing and improves post creation and API
robustness.
> 
> - Adds `WordPressGetAllPostsBlock` to fetch posts with optional
`status` filter and pagination (`number`, `offset`); outputs `found`,
`posts`, and streams each `post`
> - Enhances `WordPressCreatePostBlock` with `publish_as_draft` input
and adds `site` to outputs; sets `status` accordingly
> - WordPress API updates in `_api.py`: new `get_posts`, `Post`,
`PostsResponse`, and `normalize_site`; apply
`Requests(raise_for_status=False)` across OAuth/token/info and post
creation; better error propagation
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
10be1c4709. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Toran Bruce Richards <Torantulino@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <ntindle@users.noreply.github.com>
Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:57:47 +00:00
Abhimanyu Yadav
923d8baedc feat(frontend): add JsonTextField component for complex nested form data (#11752)
### Changes 🏗️

- Added a new `JsonTextField` component to handle complex nested JSON
types (objects/arrays inside other objects/arrays)
- Created helper functions for JSON parsing, validation, and formatting
- Implemented `useJsonTextField` hook to manage state and validation
- Enhanced `generateUiSchemaForCustomFields` to detect nested complex
types and render them as JSON text fields
- Updated `TextInputExpanderModal` to support JSON-specific styling
- Added `JSON_TEXT_FIELD_ID` constant to custom registry for field
identification

This change improves the user experience by preventing deeply nested
form UIs. Instead, complex nested structures are presented as editable
JSON text fields with proper validation and formatting.

### Before

![Screenshot 2026-01-12 at
1.07.54 PM.png](https://app.graphite.com/user-attachments/assets/dc2b96cc-562a-4e6b-8278-76de941e3bd9.png)

### After

![Screenshot 2026-01-12 at
12.35.19 PM.png](https://app.graphite.com/user-attachments/assets/ea0028a5-c119-43c3-8100-b103484e0b54.png)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Test with simple JSON objects in forms
  - [x] Test with nested arrays and objects
  - [x] Test with anyOf/oneOf schemas containing complex types
  - [x] Test the expander modal with JSON content

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* New JSON text field with expandable modal editor, inline validation,
and helpful placeholders.
* Complex nested objects/arrays now render as JSON fields to simplify
editing.
* Modal editor uses monospace, smaller text when editing JSON for
improved readability.

* **Chores**
* Added a non-functional runtime debug log (no user-facing behavior
changes).

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 12:22:41 +00:00
Abhimanyu Yadav
a55b2e02dc feat(frontend): enhance CredentialsInput and CredentialRow components with variant support (#11753)
### Changes 🏗️

- Added a new `variant` prop to `CredentialsInput` component with
options "default" or "node"
- Implemented compact styling for the "node" variant in `CredentialRow`
component
- Modified layout and overflow handling for credential display in node
context
- Added conditional rendering of masked key display based on variant
- Passed the variant prop through the component hierarchy
- Applied the "node" variant to the `CredentialsField` component with
appropriate styling

Before

![Screenshot 2026-01-12 at
4.39.35 PM.png](https://app.graphite.com/user-attachments/assets/2b605b2d-7abf-4e8a-adc5-6a6e8b712ef7.png)

After

![Screenshot 2026-01-12 at
4.55.39 PM.png](https://app.graphite.com/user-attachments/assets/20bb1452-870a-4111-a246-c4e3a3b456ea.png)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified credential selection works correctly in node context
  - [x] Confirmed compact styling is applied properly in node variant
  - [x] Tested overflow handling for long credential names
  - [x] Verified both default and node variants display correctly

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Credential input and selection components now support multiple
configurable visual variants, enabling better text display handling,
optimized layouts, and improved visual consistency across different
application contexts and specific use cases.

* **Style**
* Credential field displays now feature enhanced text truncation and
overflow management for a more polished and consistent user interface
experience.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 12:22:20 +00:00
Abhimanyu Yadav
6b6648b290 feat(frontend): add Table component with TableField renderer for tabular data input (#11751)
### Changes 🏗️

- Added a new `Table` component for handling tabular data input
- Created supporting hooks and helper functions for the Table component
- Added Storybook stories to showcase different Table configurations
- Implemented a custom `TableField` renderer for JSON Schema forms
- Updated type display info to support the new "table" format
- Added schema matcher to detect and render table fields appropriately

![Screenshot 2026-01-12 at
11.29.04 AM.png](https://app.graphite.com/user-attachments/assets/71469d59-469f-4cb0-882b-a49791fe948d.png)

![Screenshot 2026-01-12 at
11.28.54 AM.png](https://app.graphite.com/user-attachments/assets/81193f32-0e16-435e-bb66-5d2aea98266a.png)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Verified Table component renders correctly with various
configurations
  - [x] Tested adding and removing rows in the Table
- [x] Confirmed data changes are properly tracked and reported via
onChange
  - [x] Verified TableField renderer works with JSON Schema forms
  - [x] Checked that table format is properly detected in the schema

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added a Table component for displaying and editing tabular data with
support for adding/deleting rows, read-only mode, and customizable
labels.
* Added support for rendering array fields as tables in form inputs with
configurable columns and values.

* **Tests**
* Added comprehensive Storybook stories demonstrating various Table
configurations and behaviors.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 10:32:14 +00:00
Abhimanyu Yadav
c0a9c0410b feat(frontend): add MultiSelectField component and improve node title cursor styling (#11744)
## Changes 🏗️

- Added a new `MultiSelectField` component for handling multiple boolean
selections in a dropdown format
- Implemented `useMultiSelectField` hook to manage the state and logic
of the multi-select component
- Added support for custom fields in `AnyOfField` by checking if the
option schema matches a custom field
- Added `isMultiSelectSchema` utility function to detect schemas
suitable for the multi-select component
- Added hover cursor styling to node headers to indicate text
editability

![Screenshot 2026-01-10 at
11.15.12 AM.png](https://app.graphite.com/user-attachments/assets/8254497b-604f-4ccc-a40b-eb8994c073b4.png)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified that multi-select fields render correctly in the UI
  - [x] Confirmed that selecting multiple options works as expected
  - [x] Tested that the node header shows the text cursor on hover
- [x] Verified that AnyOf fields correctly use custom field renderers
when applicable

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a multi-select field allowing selection of multiple options with
improved selection UI.
* AnyOf options can now resolve and render custom field types, improving
form composition when schemas map to custom controls.

* **Style**
  * Tooltip header cursor updated for clearer hover feedback.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 09:48:58 +00:00
Abhimanyu Yadav
17a77b02c7 fix(frontend): exclude schemas with enum from anyOf detection (#11743)
### Changes 🏗️

Fixed the `isAnyOfSchema` function in schema-utils.ts to exclude schemas
that have an `enum` property. This prevents incorrect schema processing
for enums that also have anyOf definitions. Added a console.log
statement in FormRenderer.tsx to help debug schema preprocessing.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified that forms with enum values render correctly
- [x] Confirmed that anyOf schemas are properly identified and processed
- [x] Tested with various schema combinations to ensure the fix doesn't
break existing functionality

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Bug Fixes
* Improved validation logic for form field schemas to correctly handle
edge cases when multiple constraint types are defined.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 09:48:47 +00:00
Zamil Majdy
701fce83ca fix(backend): add missing metadata attribute to mock nodes in SmartDecisionMaker tests (#11750)
This PR fixes failing SmartDecisionMaker tests by adding missing
`metadata` attribute to mock nodes.

### Changes 🏗️

Mock nodes in SmartDecisionMaker tests were missing the `metadata = {}`
attribute, which was introduced in commit 4a52b7eca for the
customized_name feature. This caused tests to fail with:

```
TypeError: expected string or bytes-like object, got 'Mock'
```

**Files fixed**:
- `backend/blocks/test/test_smart_decision_maker_dict.py`: Added
`metadata = {}` to mock nodes in all 3 tests
- `backend/blocks/test/test_smart_decision_maker_dynamic_fields.py`:
Added `metadata = {}` to mock nodes in all 8 tests

**Root cause**: The `_create_block_function_signature` method calls
`sink_node.metadata.get("customized_name")`, but mock nodes in tests
didn't have the metadata attribute initialized.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Run `poetry run pytest
backend/blocks/test/test_smart_decision_maker_dict.py -xvs` - 3 passed
- [x] Run `poetry run pytest
backend/blocks/test/test_smart_decision_maker_dynamic_fields.py -xvs` -
8 passed
  - [x] All tests pass successfully

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Tests**
* Updated test infrastructure to enhance mock object configuration for
improved test reliability and consistency across test suites.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-11 17:00:36 -06:00
Zamil Majdy
78d89d0faf Merge branch 'master' of github.com:Significant-Gravitas/AutoGPT into dev 2026-01-11 13:09:23 -06:00
Zamil Majdy
f482eb668b hotfix(backend): resolve tool pin name mismatch in SmartDecisionMakerBlock (#11749)
## Root Cause

Execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2 and all similar
executions have been failing since Nov 12, 2025 when tool pin routing
was refactored to use node IDs. The SmartDecisionMakerBlock was
double-sanitizing field names when emitting tool call outputs:

```python
# Original field name from link: "Max Keyword Difficulty"
original_field_name = field_mapping.get(clean_arg_name)  #  Retrieved correctly
sanitized_arg_name = self.cleanup(original_field_name)   #  Sanitized AGAIN!
emit_key = f"tools_^_{node_id}_~_{sanitized_arg_name}"   # Emits "max_keyword_difficulty"
```

But the parser expected original names from graph links:
```python
# Parser expects: "Max Keyword Difficulty" (from link.sink_name)
# Emit provides: "max_keyword_difficulty" (sanitized)
# Result: Mismatch → Tool never executes
```

### Changes 🏗️

**1. Fixed Emit Logic** (`smart_decision_maker.py` line 1135)
- Removed double sanitization: `sanitized_arg_name =
self.cleanup(original_field_name)`
- Now emits with original field names: `emit_key =
f"tools_^_{node_id}_~_{original_field_name}"`

**2. Made Agent Nodes Consistent** (`smart_decision_maker.py` lines
497-530)
- Added `field_mapping` to agent function signatures (was missing)
- Agent signatures now sanitize property keys for Anthropic API (like
block signatures)
- Stores field_mapping for use during emit

### Impact

**Fixes:**
-  All graphs with multi-word field names (e.g., "Max Keyword
Difficulty", "Minimum Volume")
-  All graphs with special characters in field names (e.g., "API-Key")
-  Both block nodes AND agent nodes now work consistently

**Unaffected:**
- Single-word lowercase field names (e.g., "keyword", "url") - these
were already working

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified parse_execution_output handles exact match correctly
  - [x] Verified emit uses original field names
  - [x] Verified field_mapping works for both block and agent nodes
- [x] Re-run execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2 after
deployment to verify fix

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
(no changes)
- [x] `docker-compose.yml` is updated or already compatible with my
changes (no changes)
- [x] No configuration changes in this PR

### Test Plan

1. **Unit test validation** (completed):
- Field name cleanup: "Max Keyword Difficulty" →
"max_keyword_difficulty" 
   - Parse with exact match: Success 
   - Parse with mismatch: Returns None 

2. **Production validation** (to be done after deployment):
   - Re-run execution a40bdb4a-964d-4684-94e8-b148eb6bcfc2
- Verify AgentExecutor (node 767682f5-694f-4b2a-bf52-fbdcad6a4a4f)
executes successfully
   - Verify execution completes with high correctness score (not 0.20)
   - Monitor for any regressions in existing graphs

### Files Changed

- `backend/blocks/smart_decision_maker.py`: Remove double sanitization,
add agent field_mapping

### Related Issues

- Resolves execution failure a40bdb4a-964d-4684-94e8-b148eb6bcfc2
- Fixes bug introduced in commit 536e2a5ec (Nov 12, 2025)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved field name mapping consistency in the SmartDecisionMaker
block to ensure proper handling of field names throughout function
signatures and tool execution workflows.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 02:08:12 +07:00
Nicholas Tindle
4a52b7eca0 fix(backend): use customized block names in smart decision maker
The SmartDecisionMakerBlock now respects the customized_name field from
node metadata when generating tool function signatures for the LLM.

Previously, the block always used the static block.name from the block
class definition, ignoring any custom names users set in the builder UI.

Changes:
- _create_block_function_signature: Check sink_node.metadata for
  customized_name before falling back to block.name
- _create_agent_function_signature: Check sink_node.metadata for
  customized_name before falling back to sink_graph_meta.name
- Added 4 unit tests for the customized_name feature

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:51:39 -07:00
37 changed files with 1515 additions and 104 deletions

View File

@@ -401,28 +401,11 @@ async def add_generated_agent_image(
)
def _initialize_graph_settings(graph: graph_db.GraphModel) -> GraphSettings:
"""
Initialize GraphSettings based on graph content.
Args:
graph: The graph to analyze
Returns:
GraphSettings with appropriate human_in_the_loop_safe_mode value
"""
if graph.has_human_in_the_loop:
# Graph has HITL blocks - set safe mode to True by default
return GraphSettings(human_in_the_loop_safe_mode=True)
else:
# Graph has no HITL blocks - keep None
return GraphSettings(human_in_the_loop_safe_mode=None)
async def create_library_agent(
graph: graph_db.GraphModel,
user_id: str,
create_library_agents_for_sub_graphs: bool = True,
is_ai_generated: bool = False,
) -> list[library_model.LibraryAgent]:
"""
Adds an agent to the user's library (LibraryAgent table).
@@ -431,6 +414,7 @@ async def create_library_agent(
agent: The agent/Graph to add to the library.
user_id: The user to whom the agent will be added.
create_library_agents_for_sub_graphs: If True, creates LibraryAgent records for sub-graphs as well.
is_ai_generated: Whether this graph was AI-generated.
Returns:
The newly created LibraryAgent records.
@@ -465,7 +449,9 @@ async def create_library_agent(
}
},
settings=SafeJson(
_initialize_graph_settings(graph_entry).model_dump()
GraphSettings.from_graph(
graph_entry, is_ai_generated=is_ai_generated
).model_dump()
),
),
include=library_agent_include(
@@ -627,33 +613,6 @@ async def update_library_agent(
raise DatabaseError("Failed to update library agent") from e
async def update_library_agent_settings(
user_id: str,
agent_id: str,
settings: GraphSettings,
) -> library_model.LibraryAgent:
"""
Updates the settings for a specific LibraryAgent.
Args:
user_id: The owner of the LibraryAgent.
agent_id: The ID of the LibraryAgent to update.
settings: New GraphSettings to apply.
Returns:
The updated LibraryAgent.
Raises:
NotFoundError: If the specified LibraryAgent does not exist.
DatabaseError: If there's an error in the update operation.
"""
return await update_library_agent(
library_agent_id=agent_id,
user_id=user_id,
settings=settings,
)
async def delete_library_agent(
library_agent_id: str, user_id: str, soft_delete: bool = True
) -> None:
@@ -838,7 +797,9 @@ async def add_store_agent_to_library(
"isCreatedByUser": False,
"useGraphIsActiveVersion": False,
"settings": SafeJson(
_initialize_graph_settings(graph_model).model_dump()
GraphSettings.from_graph(
graph_model, is_ai_generated=False
).model_dump()
),
},
include=library_agent_include(

View File

@@ -762,10 +762,10 @@ async def create_new_graph(
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
graph.validate_graph(for_run=False)
# The return value of the create graph & library function is intentionally not used here,
# as the graph already valid and no sub-graphs are returned back.
await graph_db.create_graph(graph, user_id=user_id)
await library_db.create_library_agent(graph, user_id=user_id)
await library_db.create_library_agent(
graph, user_id=user_id, is_ai_generated=create_graph.is_ai_generated
)
activated_graph = await on_graph_activate(graph, user_id=user_id)
if create_graph.source == "builder":
@@ -889,21 +889,17 @@ async def set_graph_active_version(
async def _update_library_agent_version_and_settings(
user_id: str, agent_graph: graph_db.GraphModel
) -> library_model.LibraryAgent:
# Keep the library agent up to date with the new active version
library = await library_db.update_agent_version_in_library(
user_id, agent_graph.id, agent_graph.version
)
# If the graph has HITL node, initialize the setting if it's not already set.
if (
agent_graph.has_human_in_the_loop
and library.settings.human_in_the_loop_safe_mode is None
):
await library_db.update_library_agent_settings(
updated_settings = GraphSettings.from_graph(
agent_graph, is_ai_generated=library.settings.is_ai_generated_graph
)
if updated_settings != library.settings:
library = await library_db.update_library_agent(
library_agent_id=library.id,
user_id=user_id,
agent_id=library.id,
settings=library.settings.model_copy(
update={"human_in_the_loop_safe_mode": True}
),
settings=updated_settings,
)
return library
@@ -920,21 +916,18 @@ async def update_graph_settings(
user_id: Annotated[str, Security(get_user_id)],
) -> GraphSettings:
"""Update graph settings for the user's library agent."""
# Get the library agent for this graph
library_agent = await library_db.get_library_agent_by_graph_id(
graph_id=graph_id, user_id=user_id
)
if not library_agent:
raise HTTPException(404, f"Graph #{graph_id} not found in user's library")
# Update the library agent settings
updated_agent = await library_db.update_library_agent_settings(
updated_agent = await library_db.update_library_agent(
library_agent_id=library_agent.id,
user_id=user_id,
agent_id=library_agent.id,
settings=settings,
)
# Return the updated settings
return GraphSettings.model_validate(updated_agent.settings)

View File

@@ -43,6 +43,7 @@ GraphExecutionSource = Literal["builder", "library", "onboarding"]
class CreateGraph(pydantic.BaseModel):
graph: Graph
source: GraphCreationSource | None = None
is_ai_generated: bool = False
class CreateAPIKeyRequest(pydantic.BaseModel):

View File

@@ -391,8 +391,12 @@ class SmartDecisionMakerBlock(Block):
"""
block = sink_node.block
# Use custom name from node metadata if set, otherwise fall back to block.name
custom_name = sink_node.metadata.get("customized_name")
tool_name = custom_name if custom_name else block.name
tool_function: dict[str, Any] = {
"name": SmartDecisionMakerBlock.cleanup(block.name),
"name": SmartDecisionMakerBlock.cleanup(tool_name),
"description": block.description,
}
sink_block_input_schema = block.input_schema
@@ -489,14 +493,24 @@ class SmartDecisionMakerBlock(Block):
f"Sink graph metadata not found: {graph_id} {graph_version}"
)
# Use custom name from node metadata if set, otherwise fall back to graph name
custom_name = sink_node.metadata.get("customized_name")
tool_name = custom_name if custom_name else sink_graph_meta.name
tool_function: dict[str, Any] = {
"name": SmartDecisionMakerBlock.cleanup(sink_graph_meta.name),
"name": SmartDecisionMakerBlock.cleanup(tool_name),
"description": sink_graph_meta.description,
}
properties = {}
field_mapping = {}
for link in links:
field_name = link.sink_name
clean_field_name = SmartDecisionMakerBlock.cleanup(field_name)
field_mapping[clean_field_name] = field_name
sink_block_input_schema = sink_node.input_default["input_schema"]
sink_block_properties = sink_block_input_schema.get("properties", {}).get(
link.sink_name, {}
@@ -506,7 +520,7 @@ class SmartDecisionMakerBlock(Block):
if "description" in sink_block_properties
else f"The {link.sink_name} of the tool"
)
properties[link.sink_name] = {
properties[clean_field_name] = {
"type": "string",
"description": description,
"default": json.dumps(sink_block_properties.get("default", None)),
@@ -519,7 +533,7 @@ class SmartDecisionMakerBlock(Block):
"strict": True,
}
# Store node info for later use in output processing
tool_function["_field_mapping"] = field_mapping
tool_function["_sink_node_id"] = sink_node.id
return {"type": "function", "function": tool_function}
@@ -1147,8 +1161,9 @@ class SmartDecisionMakerBlock(Block):
original_field_name = field_mapping.get(clean_arg_name, clean_arg_name)
arg_value = tool_args.get(clean_arg_name)
sanitized_arg_name = self.cleanup(original_field_name)
emit_key = f"tools_^_{sink_node_id}_~_{sanitized_arg_name}"
# Use original_field_name directly (not sanitized) to match link sink_name
# The field_mapping already translates from LLM's cleaned names to original names
emit_key = f"tools_^_{sink_node_id}_~_{original_field_name}"
logger.debug(
"[SmartDecisionMakerBlock|geid:%s|neid:%s] emit %s",

View File

@@ -1057,3 +1057,153 @@ async def test_smart_decision_maker_traditional_mode_default():
) # Should yield individual tool parameters
assert "tools_^_test-sink-node-id_~_max_keyword_difficulty" in outputs
assert "conversations" in outputs
@pytest.mark.asyncio
async def test_smart_decision_maker_uses_customized_name_for_blocks():
"""Test that SmartDecisionMakerBlock uses customized_name from node metadata for tool names."""
from unittest.mock import MagicMock
from backend.blocks.basic import StoreValueBlock
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node with customized_name in metadata
mock_node = MagicMock(spec=Node)
mock_node.id = "test-node-id"
mock_node.block_id = StoreValueBlock().id
mock_node.metadata = {"customized_name": "My Custom Tool Name"}
mock_node.block = StoreValueBlock()
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "input"
# Call the function directly
result = await SmartDecisionMakerBlock._create_block_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the customized name (cleaned up)
assert result["type"] == "function"
assert result["function"]["name"] == "my_custom_tool_name" # Cleaned version
assert result["function"]["_sink_node_id"] == "test-node-id"
@pytest.mark.asyncio
async def test_smart_decision_maker_falls_back_to_block_name():
"""Test that SmartDecisionMakerBlock falls back to block.name when no customized_name."""
from unittest.mock import MagicMock
from backend.blocks.basic import StoreValueBlock
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node without customized_name
mock_node = MagicMock(spec=Node)
mock_node.id = "test-node-id"
mock_node.block_id = StoreValueBlock().id
mock_node.metadata = {} # No customized_name
mock_node.block = StoreValueBlock()
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "input"
# Call the function directly
result = await SmartDecisionMakerBlock._create_block_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the block's default name
assert result["type"] == "function"
assert result["function"]["name"] == "storevalueblock" # Default block name cleaned
assert result["function"]["_sink_node_id"] == "test-node-id"
@pytest.mark.asyncio
async def test_smart_decision_maker_uses_customized_name_for_agents():
"""Test that SmartDecisionMakerBlock uses customized_name from metadata for agent nodes."""
from unittest.mock import AsyncMock, MagicMock, patch
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node with customized_name in metadata
mock_node = MagicMock(spec=Node)
mock_node.id = "test-agent-node-id"
mock_node.metadata = {"customized_name": "My Custom Agent"}
mock_node.input_default = {
"graph_id": "test-graph-id",
"graph_version": 1,
"input_schema": {"properties": {"test_input": {"description": "Test input"}}},
}
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "test_input"
# Mock the database client
mock_graph_meta = MagicMock()
mock_graph_meta.name = "Original Agent Name"
mock_graph_meta.description = "Agent description"
mock_db_client = AsyncMock()
mock_db_client.get_graph_metadata.return_value = mock_graph_meta
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
return_value=mock_db_client,
):
result = await SmartDecisionMakerBlock._create_agent_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the customized name (cleaned up)
assert result["type"] == "function"
assert result["function"]["name"] == "my_custom_agent" # Cleaned version
assert result["function"]["_sink_node_id"] == "test-agent-node-id"
@pytest.mark.asyncio
async def test_smart_decision_maker_agent_falls_back_to_graph_name():
"""Test that agent node falls back to graph name when no customized_name."""
from unittest.mock import AsyncMock, MagicMock, patch
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
from backend.data.graph import Link, Node
# Create a mock node without customized_name
mock_node = MagicMock(spec=Node)
mock_node.id = "test-agent-node-id"
mock_node.metadata = {} # No customized_name
mock_node.input_default = {
"graph_id": "test-graph-id",
"graph_version": 1,
"input_schema": {"properties": {"test_input": {"description": "Test input"}}},
}
# Create a mock link
mock_link = MagicMock(spec=Link)
mock_link.sink_name = "test_input"
# Mock the database client
mock_graph_meta = MagicMock()
mock_graph_meta.name = "Original Agent Name"
mock_graph_meta.description = "Agent description"
mock_db_client = AsyncMock()
mock_db_client.get_graph_metadata.return_value = mock_graph_meta
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
return_value=mock_db_client,
):
result = await SmartDecisionMakerBlock._create_agent_function_signature(
mock_node, [mock_link]
)
# Verify the tool name uses the graph's default name
assert result["type"] == "function"
assert result["function"]["name"] == "original_agent_name" # Graph name cleaned
assert result["function"]["_sink_node_id"] == "test-agent-node-id"

View File

@@ -15,6 +15,7 @@ async def test_smart_decision_maker_handles_dynamic_dict_fields():
mock_node.block = CreateDictionaryBlock()
mock_node.block_id = CreateDictionaryBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic dictionary fields
mock_links = [
@@ -77,6 +78,7 @@ async def test_smart_decision_maker_handles_dynamic_list_fields():
mock_node.block = AddToListBlock()
mock_node.block_id = AddToListBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic list fields
mock_links = [

View File

@@ -44,6 +44,7 @@ async def test_create_block_function_signature_with_dict_fields():
mock_node.block = CreateDictionaryBlock()
mock_node.block_id = CreateDictionaryBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic dictionary fields (source sanitized, sink original)
mock_links = [
@@ -106,6 +107,7 @@ async def test_create_block_function_signature_with_list_fields():
mock_node.block = AddToListBlock()
mock_node.block_id = AddToListBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic list fields
mock_links = [
@@ -159,6 +161,7 @@ async def test_create_block_function_signature_with_object_fields():
mock_node.block = MatchTextPatternBlock()
mock_node.block_id = MatchTextPatternBlock().id
mock_node.input_default = {}
mock_node.metadata = {}
# Create mock links with dynamic object fields
mock_links = [
@@ -208,11 +211,13 @@ async def test_create_tool_node_signatures():
mock_dict_node.block = CreateDictionaryBlock()
mock_dict_node.block_id = CreateDictionaryBlock().id
mock_dict_node.input_default = {}
mock_dict_node.metadata = {}
mock_list_node = Mock()
mock_list_node.block = AddToListBlock()
mock_list_node.block_id = AddToListBlock().id
mock_list_node.input_default = {}
mock_list_node.metadata = {}
# Mock links with dynamic fields
dict_link1 = Mock(
@@ -423,6 +428,7 @@ async def test_mixed_regular_and_dynamic_fields():
mock_node.block.name = "TestBlock"
mock_node.block.description = "A test block"
mock_node.block.input_schema = Mock()
mock_node.metadata = {}
# Mock the get_field_schema to return a proper schema for regular fields
def get_field_schema(field_name):

View File

@@ -1,3 +1,3 @@
from .blog import WordPressCreatePostBlock
from .blog import WordPressCreatePostBlock, WordPressGetAllPostsBlock
__all__ = ["WordPressCreatePostBlock"]
__all__ = ["WordPressCreatePostBlock", "WordPressGetAllPostsBlock"]

View File

@@ -161,7 +161,7 @@ async def oauth_exchange_code_for_tokens(
grant_type="authorization_code",
).model_dump(exclude_none=True)
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL}oauth2/token",
headers=headers,
data=data,
@@ -205,7 +205,7 @@ async def oauth_refresh_tokens(
grant_type="refresh_token",
).model_dump(exclude_none=True)
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL}oauth2/token",
headers=headers,
data=data,
@@ -252,7 +252,7 @@ async def validate_token(
"token": token,
}
response = await Requests().get(
response = await Requests(raise_for_status=False).get(
f"{WORDPRESS_BASE_URL}oauth2/token-info",
params=params,
)
@@ -296,7 +296,7 @@ async def make_api_request(
url = f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}"
request_method = getattr(Requests(), method.lower())
request_method = getattr(Requests(raise_for_status=False), method.lower())
response = await request_method(
url,
headers=headers,
@@ -476,6 +476,7 @@ async def create_post(
data["tags"] = ",".join(str(t) for t in data["tags"])
# Make the API request
site = normalize_site(site)
endpoint = f"/rest/v1.1/sites/{site}/posts/new"
headers = {
@@ -483,7 +484,7 @@ async def create_post(
"Content-Type": "application/x-www-form-urlencoded",
}
response = await Requests().post(
response = await Requests(raise_for_status=False).post(
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
headers=headers,
data=data,
@@ -499,3 +500,132 @@ async def create_post(
)
error_message = error_data.get("message", response.text)
raise ValueError(f"Failed to create post: {response.status} - {error_message}")
class Post(BaseModel):
"""Response model for individual posts in a posts list response.
This is a simplified version compared to PostResponse, as the list endpoint
returns less detailed information than the create/get single post endpoints.
"""
ID: int
site_ID: int
author: PostAuthor
date: datetime
modified: datetime
title: str
URL: str
short_URL: str
content: str | None = None
excerpt: str | None = None
slug: str
guid: str
status: str
sticky: bool
password: str | None = ""
parent: Union[Dict[str, Any], bool, None] = None
type: str
discussion: Dict[str, Union[str, bool, int]] | None = None
likes_enabled: bool | None = None
sharing_enabled: bool | None = None
like_count: int | None = None
i_like: bool | None = None
is_reblogged: bool | None = None
is_following: bool | None = None
global_ID: str | None = None
featured_image: str | None = None
post_thumbnail: Dict[str, Any] | None = None
format: str | None = None
geo: Union[Dict[str, Any], bool, None] = None
menu_order: int | None = None
page_template: str | None = None
publicize_URLs: List[str] | None = None
terms: Dict[str, Dict[str, Any]] | None = None
tags: Dict[str, Dict[str, Any]] | None = None
categories: Dict[str, Dict[str, Any]] | None = None
attachments: Dict[str, Dict[str, Any]] | None = None
attachment_count: int | None = None
metadata: List[Dict[str, Any]] | None = None
meta: Dict[str, Any] | None = None
capabilities: Dict[str, bool] | None = None
revisions: List[int] | None = None
other_URLs: Dict[str, Any] | None = None
class PostsResponse(BaseModel):
"""Response model for WordPress posts list."""
found: int
posts: List[Post]
meta: Dict[str, Any]
def normalize_site(site: str) -> str:
"""
Normalize a site identifier by stripping protocol and trailing slashes.
Args:
site: Site URL, domain, or ID (e.g., "https://myblog.wordpress.com/", "myblog.wordpress.com", "123456789")
Returns:
Normalized site identifier (domain or ID only)
"""
site = site.strip()
if site.startswith("https://"):
site = site[8:]
elif site.startswith("http://"):
site = site[7:]
return site.rstrip("/")
async def get_posts(
credentials: Credentials,
site: str,
status: PostStatus | None = None,
number: int = 100,
offset: int = 0,
) -> PostsResponse:
"""
Get posts from a WordPress site.
Args:
credentials: OAuth credentials
site: Site ID or domain (e.g., "myblog.wordpress.com" or "123456789")
status: Filter by post status using PostStatus enum, or None for all
number: Number of posts to retrieve (max 100)
offset: Number of posts to skip (for pagination)
Returns:
PostsResponse with the list of posts
"""
site = normalize_site(site)
endpoint = f"/rest/v1.1/sites/{site}/posts"
headers = {
"Authorization": credentials.auth_header(),
}
params: Dict[str, Any] = {
"number": max(1, min(number, 100)), # 1100 posts per request
"offset": offset,
}
if status:
params["status"] = status.value
response = await Requests(raise_for_status=False).get(
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
headers=headers,
params=params,
)
if response.ok:
return PostsResponse.model_validate(response.json())
error_data = (
response.json()
if response.headers.get("content-type", "").startswith("application/json")
else {}
)
error_message = error_data.get("message", response.text)
raise ValueError(f"Failed to get posts: {response.status} - {error_message}")

View File

@@ -9,7 +9,15 @@ from backend.sdk import (
SchemaField,
)
from ._api import CreatePostRequest, PostResponse, PostStatus, create_post
from ._api import (
CreatePostRequest,
Post,
PostResponse,
PostsResponse,
PostStatus,
create_post,
get_posts,
)
from ._config import wordpress
@@ -49,8 +57,15 @@ class WordPressCreatePostBlock(Block):
media_urls: list[str] = SchemaField(
description="URLs of images to sideload and attach to the post", default=[]
)
publish_as_draft: bool = SchemaField(
description="If True, publishes the post as a draft. If False, publishes it publicly.",
default=False,
)
class Output(BlockSchemaOutput):
site: str = SchemaField(
description="The site ID or domain (pass-through for chaining with other blocks)"
)
post_id: int = SchemaField(description="The ID of the created post")
post_url: str = SchemaField(description="The full URL of the created post")
short_url: str = SchemaField(description="The shortened wp.me URL")
@@ -78,7 +93,9 @@ class WordPressCreatePostBlock(Block):
tags=input_data.tags,
featured_image=input_data.featured_image,
media_urls=input_data.media_urls,
status=PostStatus.PUBLISH,
status=(
PostStatus.DRAFT if input_data.publish_as_draft else PostStatus.PUBLISH
),
)
post_response: PostResponse = await create_post(
@@ -87,7 +104,69 @@ class WordPressCreatePostBlock(Block):
post_data=post_request,
)
yield "site", input_data.site
yield "post_id", post_response.ID
yield "post_url", post_response.URL
yield "short_url", post_response.short_URL
yield "post_data", post_response.model_dump()
class WordPressGetAllPostsBlock(Block):
"""
Fetches all posts from a WordPress.com site or Jetpack-enabled site.
Supports filtering by status and pagination.
"""
class Input(BlockSchemaInput):
credentials: CredentialsMetaInput = wordpress.credentials_field()
site: str = SchemaField(
description="Site ID or domain (e.g., 'myblog.wordpress.com' or '123456789')"
)
status: PostStatus | None = SchemaField(
description="Filter by post status, or None for all",
default=None,
)
number: int = SchemaField(
description="Number of posts to retrieve (max 100 per request)", default=20
)
offset: int = SchemaField(
description="Number of posts to skip (for pagination)", default=0
)
class Output(BlockSchemaOutput):
site: str = SchemaField(
description="The site ID or domain (pass-through for chaining with other blocks)"
)
found: int = SchemaField(description="Total number of posts found")
posts: list[Post] = SchemaField(
description="List of post objects with their details"
)
post: Post = SchemaField(
description="Individual post object (yielded for each post)"
)
def __init__(self):
super().__init__(
id="97728fa7-7f6f-4789-ba0c-f2c114119536",
description="Fetch all posts from WordPress.com or Jetpack sites",
categories={BlockCategory.SOCIAL},
input_schema=self.Input,
output_schema=self.Output,
)
async def run(
self, input_data: Input, *, credentials: Credentials, **kwargs
) -> BlockOutput:
posts_response: PostsResponse = await get_posts(
credentials=credentials,
site=input_data.site,
status=input_data.status,
number=input_data.number,
offset=input_data.offset,
)
yield "site", input_data.site
yield "found", posts_response.found
yield "posts", posts_response.posts
for post in posts_response.posts:
yield "post", post

View File

@@ -637,8 +637,11 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
- should_pause: True if execution should be paused for review
- input_data_to_use: The input data to use (may be modified by reviewer)
"""
# Skip review if not required or safe mode is disabled
if not self.requires_human_review or not execution_context.safe_mode:
if not (
self.requires_human_review
and execution_context.safe_mode
and execution_context.is_ai_generated_graph
):
return False, input_data
from backend.blocks.helpers.review import HITLReviewHelper

View File

@@ -82,6 +82,7 @@ class ExecutionContext(BaseModel):
"""
safe_mode: bool = True
is_ai_generated_graph: bool = False
user_timezone: str = "UTC"
root_execution_id: Optional[str] = None
parent_execution_id: Optional[str] = None

View File

@@ -63,6 +63,14 @@ logger = logging.getLogger(__name__)
class GraphSettings(BaseModel):
human_in_the_loop_safe_mode: bool | None = None
is_ai_generated_graph: bool = False
@classmethod
def from_graph(cls, graph: "GraphModel", is_ai_generated: bool) -> "GraphSettings":
return cls(
human_in_the_loop_safe_mode=(True if graph.has_human_in_the_loop else None),
is_ai_generated_graph=is_ai_generated,
)
class Link(BaseDbModel):

View File

@@ -877,6 +877,7 @@ async def add_graph_execution(
if settings.human_in_the_loop_safe_mode is not None
else True
),
is_ai_generated_graph=settings.is_ai_generated_graph,
user_timezone=(
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
),

View File

@@ -68,7 +68,10 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<div>
<Text variant="large-semibold" className="line-clamp-1">
<Text
variant="large-semibold"
className="line-clamp-1 hover:cursor-text"
>
{beautifyString(title).replace("Block", "").trim()}
</Text>
</div>

View File

@@ -89,6 +89,18 @@ export function extractOptions(
// get display type and color for schema types [need for type display next to field name]
export const getTypeDisplayInfo = (schema: any) => {
if (
schema?.type === "array" &&
"format" in schema &&
schema.format === "table"
) {
return {
displayType: "table",
colorClass: "!text-indigo-500",
hexColor: "#6366f1",
};
}
if (schema?.type === "string" && schema?.format) {
const formatMap: Record<
string,

View File

@@ -36,6 +36,7 @@ type Props = {
readOnly?: boolean;
isOptional?: boolean;
showTitle?: boolean;
variant?: "default" | "node";
};
export function CredentialsInput({
@@ -48,6 +49,7 @@ export function CredentialsInput({
readOnly = false,
isOptional = false,
showTitle = true,
variant = "default",
}: Props) {
const hookData = useCredentialsInput({
schema,
@@ -123,6 +125,7 @@ export function CredentialsInput({
onClearCredential={() => onSelectCredential(undefined)}
readOnly={readOnly}
allowNone={isOptional}
variant={variant}
/>
) : (
<div className="mb-4 space-y-2">

View File

@@ -30,6 +30,8 @@ type CredentialRowProps = {
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
/** When "node", applies compact styling for node context */
variant?: "default" | "node";
};
export function CredentialRow({
@@ -41,14 +43,22 @@ export function CredentialRow({
readOnly = false,
showCaret = false,
asSelectTrigger = false,
variant = "default",
}: CredentialRowProps) {
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const isNodeVariant = variant === "node";
return (
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
asSelectTrigger ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
asSelectTrigger && isNodeVariant
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: asSelectTrigger
? "border-0 bg-transparent"
: readOnly
? "w-fit"
: "",
)}
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
style={
@@ -61,19 +71,31 @@ export function CredentialRow({
<ProviderIcon className="h-3 w-3 text-white" />
</div>
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
isNodeVariant && "overflow-hidden",
)}
>
<Text
variant="body"
className="line-clamp-1 flex-[0_0_50%] text-ellipsis tracking-tight"
className={cn(
"tracking-tight",
isNodeVariant
? "truncate"
: "line-clamp-1 flex-[0_0_50%] text-ellipsis",
)}
>
{getCredentialDisplayName(credential, displayName)}
</Text>
<Text
variant="large"
className="lex-[0_0_40%] relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
{!(asSelectTrigger && isNodeVariant) && (
<Text
variant="large"
className="relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
)}
</div>
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />

View File

@@ -7,6 +7,7 @@ import {
} from "@/components/__legacy__/ui/select";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { getCredentialDisplayName } from "../../helpers";
import { CredentialRow } from "../CredentialRow/CredentialRow";
@@ -26,6 +27,8 @@ interface Props {
onClearCredential?: () => void;
readOnly?: boolean;
allowNone?: boolean;
/** When "node", applies compact styling for node context */
variant?: "default" | "node";
}
export function CredentialsSelect({
@@ -37,6 +40,7 @@ export function CredentialsSelect({
onClearCredential,
readOnly = false,
allowNone = true,
variant = "default",
}: Props) {
// Auto-select first credential if none is selected (only if allowNone is false)
useEffect(() => {
@@ -59,7 +63,12 @@ export function CredentialsSelect({
value={selectedCredentials?.id || (allowNone ? "__none__" : "")}
onValueChange={handleValueChange}
>
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
<SelectTrigger
className={cn(
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden",
)}
>
{selectedCredentials ? (
<SelectValue key={selectedCredentials.id} asChild>
<CredentialRow
@@ -75,6 +84,7 @@ export function CredentialsSelect({
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
variant={variant}
/>
</SelectValue>
) : (

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
import { Table } from "./Table";
const meta = {
title: "Molecules/Table",
component: Table,
decorators: [
(Story) => (
<TooltipProvider>
<Story />
</TooltipProvider>
),
],
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
allowAddRow: {
control: "boolean",
description: "Whether to show the Add row button",
},
allowDeleteRow: {
control: "boolean",
description: "Whether to show delete buttons for each row",
},
readOnly: {
control: "boolean",
description:
"Whether the table is read-only (renders text instead of inputs)",
},
addRowLabel: {
control: "text",
description: "Label for the Add row button",
},
},
} satisfies Meta<typeof Table>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
columns: ["name", "email", "role"],
allowAddRow: true,
allowDeleteRow: true,
},
};
export const WithDefaultValues: Story = {
args: {
columns: ["name", "email", "role"],
defaultValues: [
{ name: "John Doe", email: "john@example.com", role: "Admin" },
{ name: "Jane Smith", email: "jane@example.com", role: "User" },
{ name: "Bob Wilson", email: "bob@example.com", role: "Editor" },
],
allowAddRow: true,
allowDeleteRow: true,
},
};
export const ReadOnly: Story = {
args: {
columns: ["name", "email"],
defaultValues: [
{ name: "John Doe", email: "john@example.com" },
{ name: "Jane Smith", email: "jane@example.com" },
],
readOnly: true,
},
};
export const NoAddOrDelete: Story = {
args: {
columns: ["name", "email"],
defaultValues: [
{ name: "John Doe", email: "john@example.com" },
{ name: "Jane Smith", email: "jane@example.com" },
],
allowAddRow: false,
allowDeleteRow: false,
},
};
export const SingleColumn: Story = {
args: {
columns: ["item"],
allowAddRow: true,
allowDeleteRow: true,
addRowLabel: "Add item",
},
};
export const CustomAddLabel: Story = {
args: {
columns: ["key", "value"],
allowAddRow: true,
allowDeleteRow: true,
addRowLabel: "Add new entry",
},
};
export const KeyValuePairs: Story = {
args: {
columns: ["key", "value"],
defaultValues: [
{ key: "API_KEY", value: "sk-..." },
{ key: "DATABASE_URL", value: "postgres://..." },
],
allowAddRow: true,
allowDeleteRow: true,
addRowLabel: "Add variable",
},
};

View File

@@ -0,0 +1,133 @@
import * as React from "react";
import {
Table as BaseTable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTable, RowData } from "./useTable";
import { formatColumnTitle, formatPlaceholder } from "./helpers";
export interface TableProps {
columns: string[];
defaultValues?: RowData[];
onChange?: (rows: RowData[]) => void;
allowAddRow?: boolean;
allowDeleteRow?: boolean;
addRowLabel?: string;
className?: string;
readOnly?: boolean;
}
export function Table({
columns,
defaultValues,
onChange,
allowAddRow = true,
allowDeleteRow = true,
addRowLabel = "Add row",
className,
readOnly = false,
}: TableProps) {
const { rows, handleAddRow, handleDeleteRow, handleCellChange } = useTable({
columns,
defaultValues,
onChange,
});
const showDeleteColumn = allowDeleteRow && !readOnly;
const showAddButton = allowAddRow && !readOnly;
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="overflow-hidden rounded-xl border border-zinc-200 bg-white">
<BaseTable>
<TableHeader>
<TableRow className="border-b border-zinc-100 bg-zinc-50/50">
{columns.map((column) => (
<TableHead
key={column}
className="h-10 px-3 text-sm font-medium text-zinc-600"
>
{formatColumnTitle(column)}
</TableHead>
))}
{showDeleteColumn && <TableHead className="w-[50px]" />}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={rowIndex} className="border-none">
{columns.map((column) => (
<TableCell key={`${rowIndex}-${column}`} className="p-2">
{readOnly ? (
<Text
variant="body"
className="px-3 py-2 text-sm text-zinc-800"
>
{row[column] || "-"}
</Text>
) : (
<Input
id={`table-${rowIndex}-${column}`}
label={formatColumnTitle(column)}
hideLabel
value={row[column] ?? ""}
onChange={(e) =>
handleCellChange(rowIndex, column, e.target.value)
}
placeholder={formatPlaceholder(column)}
size="small"
wrapperClassName="mb-0"
/>
)}
</TableCell>
))}
{showDeleteColumn && (
<TableCell className="p-2">
<Button
variant="icon"
size="icon"
onClick={() => handleDeleteRow(rowIndex)}
aria-label="Delete row"
className="text-zinc-400 transition-colors hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))}
{showAddButton && (
<TableRow>
<TableCell
colSpan={columns.length + (showDeleteColumn ? 1 : 0)}
className="p-2"
>
<Button
variant="outline"
size="small"
onClick={handleAddRow}
leftIcon={<Plus className="h-4 w-4" />}
className="w-fit"
>
{addRowLabel}
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</BaseTable>
</div>
</div>
);
}
export { type RowData } from "./useTable";

View File

@@ -0,0 +1,7 @@
export const formatColumnTitle = (key: string): string => {
return key.charAt(0).toUpperCase() + key.slice(1);
};
export const formatPlaceholder = (key: string): string => {
return `Enter ${key.toLowerCase()}`;
};

View File

@@ -0,0 +1,81 @@
import { useState, useEffect } from "react";
export type RowData = Record<string, string>;
interface UseTableOptions {
columns: string[];
defaultValues?: RowData[];
onChange?: (rows: RowData[]) => void;
}
export function useTable({
columns,
defaultValues,
onChange,
}: UseTableOptions) {
const createEmptyRow = (): RowData => {
const emptyRow: RowData = {};
columns.forEach((column) => {
emptyRow[column] = "";
});
return emptyRow;
};
const [rows, setRows] = useState<RowData[]>(() => {
if (defaultValues && defaultValues.length > 0) {
return defaultValues;
}
return [];
});
useEffect(() => {
if (defaultValues !== undefined) {
setRows(defaultValues);
}
}, [defaultValues]);
const updateRows = (newRows: RowData[]) => {
setRows(newRows);
onChange?.(newRows);
};
const handleAddRow = () => {
const newRows = [...rows, createEmptyRow()];
updateRows(newRows);
};
const handleDeleteRow = (rowIndex: number) => {
const newRows = rows.filter((_, index) => index !== rowIndex);
updateRows(newRows);
};
const handleCellChange = (
rowIndex: number,
columnKey: string,
value: string,
) => {
const newRows = rows.map((row, index) => {
if (index === rowIndex) {
return {
...row,
[columnKey]: value,
};
}
return row;
});
updateRows(newRows);
};
const clearAll = () => {
updateRows([]);
};
return {
rows,
handleAddRow,
handleDeleteRow,
handleCellChange,
clearAll,
createEmptyRow,
};
}

View File

@@ -30,6 +30,8 @@ export const FormRenderer = ({
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
}, [preprocessedSchema, uiSchema]);
console.log("preprocessedSchema", preprocessedSchema);
return (
<div className={"mb-6 mt-4"}>
<Form

View File

@@ -5,19 +5,14 @@ import { useAnyOfField } from "./useAnyOfField";
import { getHandleId, updateUiOption } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { ANY_OF_FLAG } from "../../constants";
import { findCustomFieldId } from "../../registry";
export const AnyOfField = (props: FieldProps) => {
const { registry, schema } = props;
const { fields } = registry;
const { SchemaField: _SchemaField } = fields;
const { nodeId } = registry.formContext;
const { isInputConnected } = useEdgeStore();
const uiOptions = getUiOptions(props.uiSchema, props.globalUiOptions);
const Widget = getWidget({ type: "string" }, "select", registry.widgets);
const {
handleOptionChange,
enumOptions,
@@ -26,6 +21,15 @@ export const AnyOfField = (props: FieldProps) => {
field_id,
} = useAnyOfField(props);
const parentCustomFieldId = findCustomFieldId(schema);
if (parentCustomFieldId) {
return null;
}
const uiOptions = getUiOptions(props.uiSchema, props.globalUiOptions);
const Widget = getWidget({ type: "string" }, "select", registry.widgets);
const handleId = getHandleId({
uiOptions,
id: field_id + ANY_OF_FLAG,
@@ -40,12 +44,21 @@ export const AnyOfField = (props: FieldProps) => {
const isHandleConnected = isInputConnected(nodeId, handleId);
// Now anyOf can render - custom fields if the option schema matches a custom field
const optionCustomFieldId = optionSchema
? findCustomFieldId(optionSchema)
: null;
const optionUiSchema = optionCustomFieldId
? { ...updatedUiSchema, "ui:field": optionCustomFieldId }
: updatedUiSchema;
const optionsSchemaField =
(optionSchema && optionSchema.type !== "null" && (
<_SchemaField
{...props}
schema={optionSchema}
uiSchema={updatedUiSchema}
uiSchema={optionUiSchema}
/>
)) ||
null;

View File

@@ -17,6 +17,7 @@ interface InputExpanderModalProps {
defaultValue: string;
description?: string;
placeholder?: string;
inputType?: "text" | "json";
}
export const InputExpanderModal: FC<InputExpanderModalProps> = ({
@@ -27,6 +28,7 @@ export const InputExpanderModal: FC<InputExpanderModalProps> = ({
defaultValue,
description,
placeholder,
inputType = "text",
}) => {
const [tempValue, setTempValue] = useState(defaultValue);
const [isCopied, setIsCopied] = useState(false);
@@ -78,7 +80,10 @@ export const InputExpanderModal: FC<InputExpanderModalProps> = ({
hideLabel
id="input-expander-modal"
value={tempValue}
className="!min-h-[300px] rounded-2xlarge"
className={cn(
"!min-h-[300px] rounded-2xlarge",
inputType === "json" && "font-mono text-sm",
)}
onChange={(e) => setTempValue(e.target.value)}
placeholder={placeholder || "Enter text..."}
autoFocus

View File

@@ -88,6 +88,8 @@ export const CredentialsField = (props: FieldProps) => {
showTitle={false}
readOnly={formContext?.readOnly}
isOptional={!isRequired}
className="w-full"
variant="node"
/>
{/* Optional credentials toggle - only show in builder canvas, not run dialogs */}

View File

@@ -0,0 +1,124 @@
"use client";
import { FieldProps, getTemplate, getUiOptions } from "@rjsf/utils";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { ArrowsOutIcon } from "@phosphor-icons/react";
import { InputExpanderModal } from "../../base/standard/widgets/TextInput/TextInputExpanderModal";
import { getHandleId, updateUiOption } from "../../helpers";
import { useJsonTextField } from "./useJsonTextField";
import { getPlaceholder } from "./helpers";
export const JsonTextField = (props: FieldProps) => {
const {
formData,
onChange,
schema,
registry,
uiSchema,
required,
name,
fieldPathId,
} = props;
const uiOptions = getUiOptions(uiSchema);
const TitleFieldTemplate = getTemplate(
"TitleFieldTemplate",
registry,
uiOptions,
);
const fieldId = fieldPathId?.$id ?? props.id ?? "json-field";
const handleId = getHandleId({
uiOptions,
id: fieldId,
schema: schema,
});
const updatedUiSchema = updateUiOption(uiSchema, {
handleId: handleId,
});
const {
textValue,
isModalOpen,
handleChange,
handleModalOpen,
handleModalClose,
handleModalSave,
} = useJsonTextField({
formData,
onChange,
path: fieldPathId?.path,
});
const placeholder = getPlaceholder(schema);
const title = schema.title || name || "JSON Value";
return (
<div className="flex flex-col gap-2">
<TitleFieldTemplate
id={fieldId}
title={title}
required={required}
schema={schema}
uiSchema={updatedUiSchema}
registry={registry}
/>
<div className="nodrag relative flex items-center gap-2">
<Input
id={fieldId}
hideLabel={true}
type="textarea"
label=""
size="small"
wrapperClassName="mb-0 flex-1 "
value={textValue}
onChange={handleChange}
placeholder={placeholder}
required={required}
disabled={props.disabled}
className="min-h-[60px] pr-8 font-mono text-xs"
/>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleModalOpen}
type="button"
className="p-1"
>
<ArrowsOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand input</TooltipContent>
</Tooltip>
</div>
{schema.description && (
<span className="text-xs text-gray-500">{schema.description}</span>
)}
<InputExpanderModal
isOpen={isModalOpen}
onClose={handleModalClose}
onSave={handleModalSave}
title={`Edit ${title}`}
description={schema.description || "Enter valid JSON"}
defaultValue={textValue}
placeholder={placeholder}
inputType="json"
/>
</div>
);
};
export default JsonTextField;

View File

@@ -0,0 +1,67 @@
import { RJSFSchema } from "@rjsf/utils";
/**
* Converts form data to a JSON string for display
* @param formData - The data to stringify
* @returns JSON string or empty string if data is null/undefined
*/
export function stringifyFormData(formData: unknown): string {
if (formData === undefined || formData === null) {
return "";
}
try {
return JSON.stringify(formData, null, 2);
} catch {
return "";
}
}
/**
* Parses a JSON string into an object/array
* @param value - The JSON string to parse
* @returns Parsed value or undefined if parsing fails or empty
*/
export function parseJsonValue(value: string): unknown | undefined {
const trimmed = value.trim();
if (trimmed === "") {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
/**
* Gets the appropriate placeholder text based on schema type
* @param schema - The JSON schema
* @returns Placeholder string
*/
export function getPlaceholder(schema: RJSFSchema): string {
if (schema.type === "array") {
return '["item1", "item2"] or [{"key": "value"}]';
}
if (schema.type === "object") {
return '{"key": "value"}';
}
return "Enter JSON value...";
}
/**
* Checks if a JSON string is valid
* @param value - The JSON string to validate
* @returns true if valid JSON, false otherwise
*/
export function isValidJson(value: string): boolean {
if (value.trim() === "") {
return true; // Empty is considered valid (will be undefined)
}
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from "react";
import { FieldProps } from "@rjsf/utils";
import { stringifyFormData, parseJsonValue, isValidJson } from "./helpers";
type FieldOnChange = FieldProps["onChange"];
type FieldPathId = FieldProps["fieldPathId"];
interface UseJsonTextFieldOptions {
formData: unknown;
onChange: FieldOnChange;
path?: FieldPathId["path"];
}
interface UseJsonTextFieldReturn {
textValue: string;
isModalOpen: boolean;
hasError: boolean;
handleChange: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
handleModalOpen: () => void;
handleModalClose: () => void;
handleModalSave: (value: string) => void;
}
/**
* Custom hook for managing JSON text field state and handlers
*/
export function useJsonTextField({
formData,
onChange,
path,
}: UseJsonTextFieldOptions): UseJsonTextFieldReturn {
const [textValue, setTextValue] = useState(() => stringifyFormData(formData));
const [isModalOpen, setIsModalOpen] = useState(false);
const [hasError, setHasError] = useState(false);
// Update text value when formData changes externally
useEffect(() => {
const newValue = stringifyFormData(formData);
setTextValue(newValue);
setHasError(false);
}, [formData]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setTextValue(value);
// Validate JSON and update error state
const valid = isValidJson(value);
setHasError(!valid);
// Try to parse and update formData
if (value.trim() === "") {
onChange(undefined, path ?? []);
return;
}
const parsed = parseJsonValue(value);
if (parsed !== undefined) {
onChange(parsed, path ?? []);
}
},
[onChange, path],
);
const handleModalOpen = useCallback(() => {
setIsModalOpen(true);
}, []);
const handleModalClose = useCallback(() => {
setIsModalOpen(false);
}, []);
const handleModalSave = useCallback(
(value: string) => {
setTextValue(value);
setIsModalOpen(false);
// Validate and update
const valid = isValidJson(value);
setHasError(!valid);
if (value.trim() === "") {
onChange(undefined, path ?? []);
return;
}
const parsed = parseJsonValue(value);
if (parsed !== undefined) {
onChange(parsed, path ?? []);
}
},
[onChange, path],
);
return {
textValue,
isModalOpen,
hasError,
handleChange,
handleModalOpen,
handleModalClose,
handleModalSave,
};
}

View File

@@ -0,0 +1,57 @@
import React from "react";
import { FieldProps, getUiOptions } from "@rjsf/utils";
import { BlockIOObjectSubSchema } from "@/lib/autogpt-server-api/types";
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from "@/components/__legacy__/ui/multiselect";
import { cn } from "@/lib/utils";
import { useMultiSelectField } from "./useMultiSelectField";
export const MultiSelectField = (props: FieldProps) => {
const { schema, formData, onChange, fieldPathId } = props;
const uiOptions = getUiOptions(props.uiSchema);
const { optionSchema, options, selection, createChangeHandler } =
useMultiSelectField({
schema: schema as BlockIOObjectSubSchema,
formData,
});
const handleValuesChange = createChangeHandler(onChange, fieldPathId);
const displayName = schema.title || "options";
return (
<div className={cn("flex flex-col", uiOptions.className)}>
<MultiSelector
className="nodrag"
values={selection}
onValuesChange={handleValuesChange}
>
<MultiSelectorTrigger className="rounded-3xl border border-zinc-200 bg-white px-2 shadow-none">
<MultiSelectorInput
placeholder={
(schema as any).placeholder ?? `Select ${displayName}...`
}
/>
</MultiSelectorTrigger>
<MultiSelectorContent className="nowheel">
<MultiSelectorList>
{options
.map((key) => ({ ...optionSchema[key], key }))
.map(({ key, title, description }) => (
<MultiSelectorItem key={key} value={key} title={description}>
{title ?? key}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
);
};

View File

@@ -0,0 +1 @@
export { MultiSelectField } from "./MultiSelectField";

View File

@@ -0,0 +1,65 @@
import { FieldProps } from "@rjsf/utils";
import { BlockIOObjectSubSchema } from "@/lib/autogpt-server-api/types";
type FormData = Record<string, boolean> | null | undefined;
interface UseMultiSelectFieldOptions {
schema: BlockIOObjectSubSchema;
formData: FormData;
}
export function useMultiSelectField({
schema,
formData,
}: UseMultiSelectFieldOptions) {
const getOptionSchema = (): Record<string, BlockIOObjectSubSchema> => {
if (schema.properties) {
return schema.properties as Record<string, BlockIOObjectSubSchema>;
}
if (
"anyOf" in schema &&
Array.isArray(schema.anyOf) &&
schema.anyOf.length > 0 &&
"properties" in schema.anyOf[0]
) {
return (schema.anyOf[0] as BlockIOObjectSubSchema).properties as Record<
string,
BlockIOObjectSubSchema
>;
}
return {};
};
const optionSchema = getOptionSchema();
const options = Object.keys(optionSchema);
const getSelection = (): string[] => {
if (!formData || typeof formData !== "object") {
return [];
}
return Object.entries(formData)
.filter(([_, value]) => value === true)
.map(([key]) => key);
};
const selection = getSelection();
const createChangeHandler =
(
onChange: FieldProps["onChange"],
fieldPathId: FieldProps["fieldPathId"],
) =>
(values: string[]) => {
const newValue = Object.fromEntries(
options.map((opt) => [opt, values.includes(opt)]),
);
onChange(newValue, fieldPathId?.path);
};
return {
optionSchema,
options,
selection,
createChangeHandler,
};
}

View File

@@ -0,0 +1,52 @@
import { descriptionId, FieldProps, getTemplate, titleId } from "@rjsf/utils";
import { Table, RowData } from "@/components/molecules/Table/Table";
import { useMemo } from "react";
export const TableField = (props: FieldProps) => {
const { schema, formData, onChange, fieldPathId, registry, uiSchema } = props;
const itemSchema = schema.items as any;
const properties = itemSchema?.properties || {};
const columns: string[] = useMemo(() => {
return Object.keys(properties);
}, [properties]);
const handleChange = (rows: RowData[]) => {
onChange(rows, fieldPathId?.path.slice(0, -1));
};
const TitleFieldTemplate = getTemplate("TitleFieldTemplate", registry);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
);
return (
<div className="flex flex-col gap-2">
<TitleFieldTemplate
id={titleId(fieldPathId)}
title={schema.title || ""}
required={true}
schema={schema}
uiSchema={uiSchema}
registry={registry}
/>
<DescriptionFieldTemplate
id={descriptionId(fieldPathId)}
description={schema.description || ""}
schema={schema}
registry={registry}
/>
<Table
columns={columns}
defaultValues={formData}
onChange={handleChange}
allowAddRow={true}
allowDeleteRow={true}
addRowLabel="Add row"
/>
</div>
);
};

View File

@@ -1,6 +1,10 @@
import { FieldProps, RJSFSchema, RegistryFieldsType } from "@rjsf/utils";
import { CredentialsField } from "./CredentialField/CredentialField";
import { GoogleDrivePickerField } from "./GoogleDrivePickerField/GoogleDrivePickerField";
import { JsonTextField } from "./JsonTextField/JsonTextField";
import { MultiSelectField } from "./MultiSelectField/MultiSelectField";
import { isMultiSelectSchema } from "../utils/schema-utils";
import { TableField } from "./TableField/TableField";
export interface CustomFieldDefinition {
id: string;
@@ -8,6 +12,9 @@ export interface CustomFieldDefinition {
component: (props: FieldProps<any, RJSFSchema, any>) => JSX.Element | null;
}
/** Field ID for JsonTextField - used to render nested complex types as text input */
export const JSON_TEXT_FIELD_ID = "custom/json_text_field";
export const CUSTOM_FIELDS: CustomFieldDefinition[] = [
{
id: "custom/credential_field",
@@ -30,6 +37,28 @@ export const CUSTOM_FIELDS: CustomFieldDefinition[] = [
},
component: GoogleDrivePickerField,
},
{
id: "custom/json_text_field",
// Not matched by schema - assigned via uiSchema for nested complex types
matcher: () => false,
component: JsonTextField,
},
{
id: "custom/multi_select_field",
matcher: isMultiSelectSchema,
component: MultiSelectField,
},
{
id: "custom/table_field",
matcher: (schema: any) => {
return (
schema.type === "array" &&
"format" in schema &&
schema.format === "table"
);
},
component: TableField,
},
];
export function findCustomFieldId(schema: any): string | null {

View File

@@ -1,19 +1,46 @@
import { RJSFSchema, UiSchema } from "@rjsf/utils";
import { findCustomFieldId } from "../custom/custom-registry";
import {
findCustomFieldId,
JSON_TEXT_FIELD_ID,
} from "../custom/custom-registry";
function isComplexType(schema: RJSFSchema): boolean {
return schema.type === "object" || schema.type === "array";
}
function hasComplexAnyOfOptions(schema: RJSFSchema): boolean {
const options = schema.anyOf || schema.oneOf;
if (!Array.isArray(options)) return false;
return options.some(
(opt: any) =>
opt &&
typeof opt === "object" &&
(opt.type === "object" || opt.type === "array"),
);
}
/**
* Generates uiSchema with ui:field settings for custom fields based on schema matchers.
* This is the standard RJSF way to route fields to custom components.
*
* Nested complex types (arrays/objects inside arrays/objects) are rendered as JsonTextField
* to avoid deeply nested form UIs. Users can enter raw JSON for these fields.
*
* @param schema - The JSON schema
* @param existingUiSchema - Existing uiSchema to merge with
* @param insideComplexType - Whether we're already inside a complex type (object/array)
*/
export function generateUiSchemaForCustomFields(
schema: RJSFSchema,
existingUiSchema: UiSchema = {},
insideComplexType: boolean = false,
): UiSchema {
const uiSchema: UiSchema = { ...existingUiSchema };
if (schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (propSchema && typeof propSchema === "object") {
// First check for custom field matchers (credentials, google drive, etc.)
const customFieldId = findCustomFieldId(propSchema);
if (customFieldId) {
@@ -21,8 +48,33 @@ export function generateUiSchemaForCustomFields(
...(uiSchema[key] as object),
"ui:field": customFieldId,
};
// Skip further processing for custom fields
continue;
}
// Handle nested complex types - render as JsonTextField
if (insideComplexType && isComplexType(propSchema as RJSFSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
"ui:field": JSON_TEXT_FIELD_ID,
};
// Don't recurse further - this field is now a text input
continue;
}
// Handle anyOf/oneOf inside complex types
if (
insideComplexType &&
hasComplexAnyOfOptions(propSchema as RJSFSchema)
) {
uiSchema[key] = {
...(uiSchema[key] as object),
"ui:field": JSON_TEXT_FIELD_ID,
};
continue;
}
// Recurse into object properties
if (
propSchema.type === "object" &&
propSchema.properties &&
@@ -31,6 +83,7 @@ export function generateUiSchemaForCustomFields(
const nestedUiSchema = generateUiSchemaForCustomFields(
propSchema as RJSFSchema,
(uiSchema[key] as UiSchema) || {},
true, // Now inside a complex type
);
uiSchema[key] = {
...(uiSchema[key] as object),
@@ -38,9 +91,11 @@ export function generateUiSchemaForCustomFields(
};
}
// Handle arrays
if (propSchema.type === "array" && propSchema.items) {
const itemsSchema = propSchema.items as RJSFSchema;
if (itemsSchema && typeof itemsSchema === "object") {
// Check for custom field on array items
const itemsCustomFieldId = findCustomFieldId(itemsSchema);
if (itemsCustomFieldId) {
uiSchema[key] = {
@@ -49,10 +104,28 @@ export function generateUiSchemaForCustomFields(
"ui:field": itemsCustomFieldId,
},
};
} else if (isComplexType(itemsSchema)) {
// Array items that are complex types become JsonTextField
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (hasComplexAnyOfOptions(itemsSchema)) {
// Array items with anyOf containing complex types become JsonTextField
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (itemsSchema.properties) {
// Recurse into object items (but they're now inside a complex type)
const itemsUiSchema = generateUiSchemaForCustomFields(
itemsSchema,
((uiSchema[key] as UiSchema)?.items as UiSchema) || {},
true, // Inside complex type (array)
);
if (Object.keys(itemsUiSchema).length > 0) {
uiSchema[key] = {
@@ -63,6 +136,61 @@ export function generateUiSchemaForCustomFields(
}
}
}
// Handle anyOf/oneOf at root level - process complex options
if (!insideComplexType) {
const anyOfOptions = propSchema.anyOf || propSchema.oneOf;
if (Array.isArray(anyOfOptions)) {
for (let i = 0; i < anyOfOptions.length; i++) {
const option = anyOfOptions[i] as RJSFSchema;
if (option && typeof option === "object") {
// Handle anyOf array options with complex items
if (option.type === "array" && option.items) {
const itemsSchema = option.items as RJSFSchema;
if (itemsSchema && typeof itemsSchema === "object") {
// Array items that are complex types become JsonTextField
if (isComplexType(itemsSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
} else if (hasComplexAnyOfOptions(itemsSchema)) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": JSON_TEXT_FIELD_ID,
},
};
}
}
}
// Recurse into anyOf object options with properties
if (
option.type === "object" &&
option.properties &&
typeof option.properties === "object"
) {
const optionUiSchema = generateUiSchemaForCustomFields(
option,
{},
true, // Inside complex type (anyOf object option)
);
if (Object.keys(optionUiSchema).length > 0) {
// Store under the property key - RJSF will apply it
uiSchema[key] = {
...(uiSchema[key] as object),
...optionUiSchema,
};
}
}
}
}
}
}
}
}
}

View File

@@ -1,7 +1,11 @@
import { getUiOptions, RJSFSchema, UiSchema } from "@rjsf/utils";
export function isAnyOfSchema(schema: RJSFSchema | undefined): boolean {
return Array.isArray(schema?.anyOf) && schema!.anyOf.length > 0;
return (
Array.isArray(schema?.anyOf) &&
schema!.anyOf.length > 0 &&
schema?.enum === undefined
);
}
export const isAnyOfChild = (
@@ -33,3 +37,21 @@ export function isOptionalType(schema: RJSFSchema | undefined): {
export function isAnyOfSelector(name: string) {
return name.includes("anyof_select");
}
export function isMultiSelectSchema(schema: RJSFSchema | undefined): boolean {
if (typeof schema !== "object" || schema === null) {
return false;
}
if ("anyOf" in schema || "oneOf" in schema) {
return false;
}
return !!(
schema.type === "object" &&
schema.properties &&
Object.values(schema.properties).every(
(prop: any) => prop.type === "boolean",
)
);
}