From 6b6648b290662587cb4c5767c14eccf1c5f47634 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:02:14 +0530 Subject: [PATCH 1/4] feat(frontend): add Table component with TableField renderer for tabular data input (#11751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 ## 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. ✏️ Tip: You can customize this high-level summary in your review settings. --- .../components/FlowEditor/nodes/helpers.ts | 12 ++ .../molecules/Table/Table.stories.tsx | 116 +++++++++++++++ .../src/components/molecules/Table/Table.tsx | 133 ++++++++++++++++++ .../src/components/molecules/Table/helpers.ts | 7 + .../components/molecules/Table/useTable.ts | 81 +++++++++++ .../InputRenderer/base/anyof/AnyOfField.tsx | 15 +- .../custom/TableField/TableField.tsx | 52 +++++++ .../InputRenderer/custom/custom-registry.ts | 12 ++ 8 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 autogpt_platform/frontend/src/components/molecules/Table/Table.stories.tsx create mode 100644 autogpt_platform/frontend/src/components/molecules/Table/Table.tsx create mode 100644 autogpt_platform/frontend/src/components/molecules/Table/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/molecules/Table/useTable.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/TableField/TableField.tsx diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts index 39384485f5..46032a67ea 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts @@ -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, diff --git a/autogpt_platform/frontend/src/components/molecules/Table/Table.stories.tsx b/autogpt_platform/frontend/src/components/molecules/Table/Table.stories.tsx new file mode 100644 index 0000000000..6dfb0b378f --- /dev/null +++ b/autogpt_platform/frontend/src/components/molecules/Table/Table.stories.tsx @@ -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) => ( + + + + ), + ], + 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; + +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/autogpt_platform/frontend/src/components/molecules/Table/Table.tsx b/autogpt_platform/frontend/src/components/molecules/Table/Table.tsx new file mode 100644 index 0000000000..a09a8344a5 --- /dev/null +++ b/autogpt_platform/frontend/src/components/molecules/Table/Table.tsx @@ -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 ( +
+
+ + + + {columns.map((column) => ( + + {formatColumnTitle(column)} + + ))} + {showDeleteColumn && } + + + + {rows.map((row, rowIndex) => ( + + {columns.map((column) => ( + + {readOnly ? ( + + {row[column] || "-"} + + ) : ( + + handleCellChange(rowIndex, column, e.target.value) + } + placeholder={formatPlaceholder(column)} + size="small" + wrapperClassName="mb-0" + /> + )} + + ))} + {showDeleteColumn && ( + + + + )} + + ))} + {showAddButton && ( + + + + + + )} + + +
+
+ ); +} + +export { type RowData } from "./useTable"; diff --git a/autogpt_platform/frontend/src/components/molecules/Table/helpers.ts b/autogpt_platform/frontend/src/components/molecules/Table/helpers.ts new file mode 100644 index 0000000000..3ea116095a --- /dev/null +++ b/autogpt_platform/frontend/src/components/molecules/Table/helpers.ts @@ -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()}`; +}; diff --git a/autogpt_platform/frontend/src/components/molecules/Table/useTable.ts b/autogpt_platform/frontend/src/components/molecules/Table/useTable.ts new file mode 100644 index 0000000000..085c18aa74 --- /dev/null +++ b/autogpt_platform/frontend/src/components/molecules/Table/useTable.ts @@ -0,0 +1,81 @@ +import { useState, useEffect } from "react"; + +export type RowData = Record; + +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(() => { + 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, + }; +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/AnyOfField.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/AnyOfField.tsx index d00925bfde..3040d11b40 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/AnyOfField.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/base/anyof/AnyOfField.tsx @@ -12,13 +12,7 @@ export const AnyOfField = (props: FieldProps) => { 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, @@ -27,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, diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/TableField/TableField.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/TableField/TableField.tsx new file mode 100644 index 0000000000..b48eca3238 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/TableField/TableField.tsx @@ -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 ( +
+ + + + + + ); +}; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts index cf5d916164..caec56d15a 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts @@ -3,6 +3,7 @@ import { CredentialsField } from "./CredentialField/CredentialField"; import { GoogleDrivePickerField } from "./GoogleDrivePickerField/GoogleDrivePickerField"; import { MultiSelectField } from "./MultiSelectField/MultiSelectField"; import { isMultiSelectSchema } from "../utils/schema-utils"; +import { TableField } from "./TableField/TableField"; export interface CustomFieldDefinition { id: string; @@ -37,6 +38,17 @@ export const CUSTOM_FIELDS: CustomFieldDefinition[] = [ 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 { From a55b2e02dccc19f8185195cb4dc6ac12e61a3cd2 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:52:20 +0530 Subject: [PATCH 2/4] feat(frontend): enhance CredentialsInput and CredentialRow components with variant support (#11753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 ## 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. ✏️ Tip: You can customize this high-level summary in your review settings. --- .../CredentialsInputs/CredentialsInputs.tsx | 3 ++ .../CredentialRow/CredentialRow.tsx | 40 ++++++++++++++----- .../CredentialsSelect/CredentialsSelect.tsx | 12 +++++- .../CredentialField/CredentialField.tsx | 2 + 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx index 79767c0c81..a0f9376aa2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx @@ -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} /> ) : (
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialRow/CredentialRow.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialRow/CredentialRow.tsx index 21ec1200e4..2d0358aacb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialRow/CredentialRow.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialRow/CredentialRow.tsx @@ -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 (
-
+
{getCredentialDisplayName(credential, displayName)} - - {"*".repeat(MASKED_KEY_LENGTH)} - + {!(asSelectTrigger && isNodeVariant) && ( + + {"*".repeat(MASKED_KEY_LENGTH)} + + )}
{showCaret && !asSelectTrigger && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx index 1ada56eb30..6e1ec2afb1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx @@ -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} > - + {selectedCredentials ? ( {}} readOnly={readOnly} asSelectTrigger={true} + variant={variant} /> ) : ( diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx index 189b73e34b..707b48f9d9 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/CredentialField/CredentialField.tsx @@ -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 */} From 923d8baedca3be8e266138e856ae576f9df24ed9 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:52:41 +0530 Subject: [PATCH 3/4] feat(frontend): add JsonTextField component for complex nested form data (#11752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 ## 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). ✏️ Tip: You can customize this high-level summary in your review settings. --- .../renderers/InputRenderer/FormRenderer.tsx | 2 + .../TextInput/TextInputExpanderModal.tsx | 7 +- .../custom/JsonTextField/JsonTextField.tsx | 124 +++++++++++++++++ .../custom/JsonTextField/helpers.ts | 67 +++++++++ .../custom/JsonTextField/useJsonTextField.ts | 107 ++++++++++++++ .../InputRenderer/custom/custom-registry.ts | 10 ++ .../InputRenderer/utils/generate-ui-schema.ts | 130 +++++++++++++++++- 7 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/JsonTextField.tsx create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/useJsonTextField.ts diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx index f784b64516..da0e3d6683 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/FormRenderer.tsx @@ -30,6 +30,8 @@ export const FormRenderer = ({ return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema); }, [preprocessedSchema, uiSchema]); + console.log("preprocessedSchema", preprocessedSchema); + return (
= ({ @@ -27,6 +28,7 @@ export const InputExpanderModal: FC = ({ defaultValue, description, placeholder, + inputType = "text", }) => { const [tempValue, setTempValue] = useState(defaultValue); const [isCopied, setIsCopied] = useState(false); @@ -78,7 +80,10 @@ export const InputExpanderModal: FC = ({ 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 diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/JsonTextField.tsx b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/JsonTextField.tsx new file mode 100644 index 0000000000..dc7738320a --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/JsonTextField.tsx @@ -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 ( +
+ +
+ + + + + + + Expand input + +
+ {schema.description && ( + {schema.description} + )} + + +
+ ); +}; + +export default JsonTextField; diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/helpers.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/helpers.ts new file mode 100644 index 0000000000..fea0f20dbc --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/helpers.ts @@ -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; + } +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/useJsonTextField.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/useJsonTextField.ts new file mode 100644 index 0000000000..85dc69cfd3 --- /dev/null +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/JsonTextField/useJsonTextField.ts @@ -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, + ) => 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) => { + 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, + }; +} diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts index caec56d15a..30d2c27a5a 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/custom/custom-registry.ts @@ -1,6 +1,7 @@ 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"; @@ -11,6 +12,9 @@ export interface CustomFieldDefinition { component: (props: FieldProps) => 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", @@ -33,6 +37,12 @@ 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, diff --git a/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/generate-ui-schema.ts b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/generate-ui-schema.ts index 4a2f4fc44a..4012c39068 100644 --- a/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/generate-ui-schema.ts +++ b/autogpt_platform/frontend/src/components/renderers/InputRenderer/utils/generate-ui-schema.ts @@ -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, + }; + } + } + } + } + } + } } } } From db8b43bb3dd848ab15030be2525d72625b2877c3 Mon Sep 17 00:00:00 2001 From: Toran Bruce Richards Date: Mon, 12 Jan 2026 19:57:47 +0000 Subject: [PATCH 4/4] feat(blocks): Add WordPress Get All Posts block and Publish Post draft toggle (#11003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. ## 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. --- > [!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 > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 10be1c47093bd57d092e434927465542f89cde87. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Toran Bruce Richards Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Nicholas Tindle Co-authored-by: Nicholas Tindle Co-authored-by: Claude Opus 4.5 --- .../backend/blocks/wordpress/__init__.py | 4 +- .../backend/backend/blocks/wordpress/_api.py | 140 +++++++++++++++++- .../backend/backend/blocks/wordpress/blog.py | 83 ++++++++++- 3 files changed, 218 insertions(+), 9 deletions(-) diff --git a/autogpt_platform/backend/backend/blocks/wordpress/__init__.py b/autogpt_platform/backend/backend/blocks/wordpress/__init__.py index c7b1e26eea..3eae4a1063 100644 --- a/autogpt_platform/backend/backend/blocks/wordpress/__init__.py +++ b/autogpt_platform/backend/backend/blocks/wordpress/__init__.py @@ -1,3 +1,3 @@ -from .blog import WordPressCreatePostBlock +from .blog import WordPressCreatePostBlock, WordPressGetAllPostsBlock -__all__ = ["WordPressCreatePostBlock"] +__all__ = ["WordPressCreatePostBlock", "WordPressGetAllPostsBlock"] diff --git a/autogpt_platform/backend/backend/blocks/wordpress/_api.py b/autogpt_platform/backend/backend/blocks/wordpress/_api.py index 78f535947b..d21dc3e05d 100644 --- a/autogpt_platform/backend/backend/blocks/wordpress/_api.py +++ b/autogpt_platform/backend/backend/blocks/wordpress/_api.py @@ -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)), # 1–100 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}") diff --git a/autogpt_platform/backend/backend/blocks/wordpress/blog.py b/autogpt_platform/backend/backend/blocks/wordpress/blog.py index c0ad5eca54..22b691480b 100644 --- a/autogpt_platform/backend/backend/blocks/wordpress/blog.py +++ b/autogpt_platform/backend/backend/blocks/wordpress/blog.py @@ -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