feat(platform/dashboard): Enable editing for agent submissions (#10545)

- resolves -
https://github.com/Significant-Gravitas/AutoGPT/issues/10511

In this PR, I’ve added backend endpoints and a frontend UI for edit
functionality on the Agent Dashboard. Now, users can update their store
submission, if status is `PENDING` or `APPROVED`, but not for `REJECTED`
and `DRAFT`. When users make changes to a pending status submission, the
changes are made to the same version. However, when users make changes
to an approved status submission, a new store listing version is
created.

Backend works something like this: 

<img width="866" height="832" alt="Screenshot 2025-08-15 at 9 39 02 AM"
src="https://github.com/user-attachments/assets/209c60ac-8350-43c1-ba4c-7378d95ecba7"
/>

### Changes
- I’ve updated the `StoreSubmission` view to include `video_url` and
`categories`.
- I’ve added a new frontend UI for editing submissions.
- I’ve created an endpoint for editing submissions.
- I’ve added more end-to-end tests to ensure the edit submission
functionality works as expected.

### 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] I have checked manually, everything is working perfectly.
  - [x] All e2e tests are also passing.

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
Co-authored-by: neo <neo.dowithless@gmail.com>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
Co-authored-by: Swifty <craigswift13@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ubbe <hi@ubbe.dev>
Co-authored-by: Lluis Agusti <hi@llu.lu>
This commit is contained in:
Abhimanyu Yadav
2025-08-20 08:19:29 +05:30
committed by GitHub
parent 0c09b0c459
commit 2610c4579f
30 changed files with 1215 additions and 138 deletions

View File

@@ -34,10 +34,10 @@ from backend.data.model import (
from backend.data.notifications import NotificationEventModel, RefundRequestData
from backend.data.user import get_user_by_id, get_user_email_by_id
from backend.notifications.notifications import queue_notification_async
from backend.server.model import Pagination
from backend.server.v2.admin.model import UserHistoryResponse
from backend.util.exceptions import InsufficientBalanceError
from backend.util.json import SafeJson
from backend.util.models import Pagination
from backend.util.retry import func_retry
from backend.util.settings import Settings

View File

@@ -60,21 +60,6 @@ class UpdatePermissionsRequest(pydantic.BaseModel):
permissions: list[APIKeyPermission]
class Pagination(pydantic.BaseModel):
total_items: int = pydantic.Field(
description="Total number of items.", examples=[42]
)
total_pages: int = pydantic.Field(
description="Total number of pages.", examples=[2]
)
current_page: int = pydantic.Field(
description="Current_page page number.", examples=[1]
)
page_size: int = pydantic.Field(
description="Number of items per page.", examples=[25]
)
class RequestTopUp(pydantic.BaseModel):
credit_amount: int

View File

@@ -14,7 +14,7 @@ import backend.server.v2.admin.credit_admin_routes as credit_admin_routes
import backend.server.v2.admin.model as admin_model
from backend.data.model import UserTransaction
from backend.server.conftest import ADMIN_USER_ID, TARGET_USER_ID
from backend.server.model import Pagination
from backend.util.models import Pagination
app = fastapi.FastAPI()
app.include_router(credit_admin_routes.router)

View File

@@ -1,7 +1,7 @@
from pydantic import BaseModel
from backend.data.model import UserTransaction
from backend.server.model import Pagination
from backend.util.models import Pagination
class UserHistoryResponse(BaseModel):

View File

@@ -9,7 +9,6 @@ import prisma.models
import prisma.types
import backend.data.graph as graph_db
import backend.server.model
import backend.server.v2.library.model as library_model
import backend.server.v2.store.exceptions as store_exceptions
import backend.server.v2.store.image_gen as store_image_gen
@@ -23,6 +22,7 @@ from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.webhooks.graph_lifecycle_hooks import on_graph_activate
from backend.util.exceptions import NotFoundError
from backend.util.json import SafeJson
from backend.util.models import Pagination
from backend.util.settings import Config
logger = logging.getLogger(__name__)
@@ -131,7 +131,7 @@ async def list_library_agents(
# Return the response with only valid agents
return library_model.LibraryAgentResponse(
agents=valid_library_agents,
pagination=backend.server.model.Pagination(
pagination=Pagination(
total_items=agent_count,
total_pages=(agent_count + page_size - 1) // page_size,
current_page=page,
@@ -629,7 +629,7 @@ async def list_presets(
return library_model.LibraryAgentPresetResponse(
presets=presets,
pagination=backend.server.model.Pagination(
pagination=Pagination(
total_items=total_items,
total_pages=total_pages,
current_page=page,

View File

@@ -8,9 +8,9 @@ import pydantic
import backend.data.block as block_model
import backend.data.graph as graph_model
import backend.server.model as server_model
from backend.data.model import CredentialsMetaInput, is_credentials_field_name
from backend.integrations.providers import ProviderName
from backend.util.models import Pagination
class LibraryAgentStatus(str, Enum):
@@ -213,7 +213,7 @@ class LibraryAgentResponse(pydantic.BaseModel):
"""Response schema for a list of library agents and pagination info."""
agents: list[LibraryAgent]
pagination: server_model.Pagination
pagination: Pagination
class LibraryAgentPresetCreatable(pydantic.BaseModel):
@@ -317,7 +317,7 @@ class LibraryAgentPresetResponse(pydantic.BaseModel):
"""Response schema for a list of agent presets and pagination info."""
presets: list[LibraryAgentPreset]
pagination: server_model.Pagination
pagination: Pagination
class LibraryAgentFilter(str, Enum):

View File

@@ -7,9 +7,9 @@ import pytest
import pytest_mock
from pytest_snapshot.plugin import Snapshot
import backend.server.model as server_model
import backend.server.v2.library.model as library_model
from backend.server.v2.library.routes import router as library_router
from backend.util.models import Pagination
app = fastapi.FastAPI()
app.include_router(library_router)
@@ -77,7 +77,7 @@ async def test_get_library_agents_success(
updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0),
),
],
pagination=server_model.Pagination(
pagination=Pagination(
total_items=2, total_pages=1, current_page=1, page_size=50
),
)

View File

@@ -466,6 +466,8 @@ async def get_store_submissions(
# internal_comments omitted for regular users
reviewed_at=sub.reviewed_at,
changes_summary=sub.changes_summary,
video_url=sub.video_url,
categories=sub.categories,
)
submission_models.append(submission_model)
@@ -546,7 +548,7 @@ async def create_store_submission(
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str = "Initial Submission",
changes_summary: str | None = "Initial Submission",
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create the first (and only) store listing and thus submission as a normal user
@@ -685,6 +687,160 @@ async def create_store_submission(
) from e
async def edit_store_submission(
user_id: str,
store_listing_version_id: str,
name: str,
video_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str | None = "Update submission",
) -> backend.server.v2.store.model.StoreSubmission:
"""
Edit an existing store listing submission.
Args:
user_id: ID of the authenticated user editing the submission
store_listing_version_id: ID of the store listing version to edit
agent_id: ID of the agent being submitted
agent_version: Version of the agent being submitted
slug: URL slug for the listing (only changeable for PENDING submissions)
name: Name of the agent
video_url: Optional URL to video demo
image_urls: List of image URLs for the listing
description: Description of the agent
sub_heading: Optional sub-heading for the agent
categories: List of categories for the agent
changes_summary: Summary of changes made in this submission
Returns:
StoreSubmission: The updated store submission
Raises:
SubmissionNotFoundError: If the submission is not found
UnauthorizedError: If the user doesn't own the submission
InvalidOperationError: If trying to edit a submission that can't be edited
"""
try:
# Get the current version and verify ownership
current_version = await prisma.models.StoreListingVersion.prisma().find_first(
where=prisma.types.StoreListingVersionWhereInput(
id=store_listing_version_id
),
include={
"StoreListing": {
"include": {
"Versions": {"order_by": {"version": "desc"}, "take": 1}
}
}
},
)
if not current_version:
raise backend.server.v2.store.exceptions.SubmissionNotFoundError(
f"Store listing version not found: {store_listing_version_id}"
)
# Verify the user owns this submission
if (
not current_version.StoreListing
or current_version.StoreListing.owningUserId != user_id
):
raise backend.server.v2.store.exceptions.UnauthorizedError(
f"User {user_id} does not own submission {store_listing_version_id}"
)
# Currently we are not allowing user to update the agent associated with a submission
# If we allow it in future, then we need a check here to verify the agent belongs to this user.
# Check if we can edit this submission
if current_version.submissionStatus == prisma.enums.SubmissionStatus.REJECTED:
raise backend.server.v2.store.exceptions.InvalidOperationError(
"Cannot edit a rejected submission"
)
# For APPROVED submissions, we need to create a new version
if current_version.submissionStatus == prisma.enums.SubmissionStatus.APPROVED:
# Create a new version for the existing listing
return await create_store_version(
user_id=user_id,
agent_id=current_version.agentGraphId,
agent_version=current_version.agentGraphVersion,
store_listing_id=current_version.storeListingId,
name=name,
video_url=video_url,
image_urls=image_urls,
description=description,
sub_heading=sub_heading,
categories=categories,
changes_summary=changes_summary,
)
# For PENDING submissions, we can update the existing version
elif current_version.submissionStatus == prisma.enums.SubmissionStatus.PENDING:
# Update the existing version
updated_version = await prisma.models.StoreListingVersion.prisma().update(
where={"id": store_listing_version_id},
data=prisma.types.StoreListingVersionUpdateInput(
name=name,
videoUrl=video_url,
imageUrls=image_urls,
description=description,
categories=categories,
subHeading=sub_heading,
changesSummary=changes_summary,
),
)
logger.debug(
f"Updated existing version {store_listing_version_id} for agent {current_version.agentGraphId}"
)
if not updated_version:
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update store listing version"
)
return backend.server.v2.store.model.StoreSubmission(
agent_id=current_version.agentGraphId,
agent_version=current_version.agentGraphVersion,
name=name,
sub_heading=sub_heading,
slug=current_version.StoreListing.slug,
description=description,
image_urls=image_urls,
date_submitted=updated_version.submittedAt or updated_version.createdAt,
status=updated_version.submissionStatus,
runs=0,
rating=0.0,
store_listing_version_id=updated_version.id,
changes_summary=changes_summary,
video_url=video_url,
categories=categories,
version=updated_version.version,
)
else:
raise backend.server.v2.store.exceptions.InvalidOperationError(
f"Cannot edit submission with status: {current_version.submissionStatus}"
)
except (
backend.server.v2.store.exceptions.SubmissionNotFoundError,
backend.server.v2.store.exceptions.UnauthorizedError,
backend.server.v2.store.exceptions.AgentNotFoundError,
backend.server.v2.store.exceptions.ListingExistsError,
backend.server.v2.store.exceptions.InvalidOperationError,
):
raise
except prisma.errors.PrismaError as e:
logger.error(f"Database error editing store submission: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to edit store submission"
) from e
async def create_store_version(
user_id: str,
agent_id: str,
@@ -696,7 +852,7 @@ async def create_store_version(
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str = "Update Submission",
changes_summary: str | None = "Initial submission",
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new version for an existing store listing

View File

@@ -94,3 +94,15 @@ class SubmissionNotFoundError(StoreError):
"""Raised when a submission is not found"""
pass
class InvalidOperationError(StoreError):
"""Raised when an operation is not valid for the current state"""
pass
class UnauthorizedError(StoreError):
"""Raised when a user is not authorized to perform an action"""
pass

View File

@@ -4,7 +4,7 @@ from typing import List
import prisma.enums
import pydantic
from backend.server.model import Pagination
from backend.util.models import Pagination
class MyAgent(pydantic.BaseModel):
@@ -115,11 +115,9 @@ class StoreSubmission(pydantic.BaseModel):
reviewed_at: datetime.datetime | None = None
changes_summary: str | None = None
reviewer_id: str | None = None
review_comments: str | None = None # External comments visible to creator
internal_comments: str | None = None # Private notes for admin use only
reviewed_at: datetime.datetime | None = None
changes_summary: str | None = None
# Additional fields for editing
video_url: str | None = None
categories: list[str] = []
class StoreSubmissionsResponse(pydantic.BaseModel):
@@ -161,6 +159,16 @@ class StoreSubmissionRequest(pydantic.BaseModel):
changes_summary: str | None = None
class StoreSubmissionEditRequest(pydantic.BaseModel):
name: str
sub_heading: str
video_url: str | None = None
image_urls: list[str] = []
description: str = ""
categories: list[str] = []
changes_summary: str | None = None
class ProfileDetails(pydantic.BaseModel):
name: str
username: str

View File

@@ -564,6 +564,47 @@ async def create_submission(
)
@router.put(
"/submissions/{store_listing_version_id}",
summary="Edit store submission",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreSubmission,
)
async def edit_submission(
store_listing_version_id: str,
submission_request: backend.server.v2.store.model.StoreSubmissionEditRequest,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
):
"""
Edit an existing store listing submission.
Args:
store_listing_version_id (str): ID of the store listing version to edit
submission_request (StoreSubmissionRequest): The updated submission details
user_id (str): ID of the authenticated user editing the listing
Returns:
StoreSubmission: The updated store submission
Raises:
HTTPException: If there is an error editing the submission
"""
return await backend.server.v2.store.db.edit_store_submission(
user_id=user_id,
store_listing_version_id=store_listing_version_id,
name=submission_request.name,
video_url=submission_request.video_url,
image_urls=submission_request.image_urls,
description=submission_request.description,
sub_heading=submission_request.sub_heading,
categories=submission_request.categories,
changes_summary=submission_request.changes_summary,
)
@router.post(
"/submissions/media",
summary="Upload submission media",

View File

@@ -551,6 +551,8 @@ def test_get_submissions_success(
agent_version=1,
sub_heading="Test agent subheading",
slug="test-agent",
video_url="test.mp4",
categories=["test-category"],
)
],
pagination=backend.server.v2.store.model.Pagination(

View File

@@ -0,0 +1,20 @@
"""
Shared models and types used across the backend to avoid circular imports.
"""
import pydantic
class Pagination(pydantic.BaseModel):
total_items: int = pydantic.Field(
description="Total number of items.", examples=[42]
)
total_pages: int = pydantic.Field(
description="Total number of pages.", examples=[2]
)
current_page: int = pydantic.Field(
description="Current_page page number.", examples=[1]
)
page_size: int = pydantic.Field(
description="Number of items per page.", examples=[25]
)

View File

@@ -0,0 +1,41 @@
-- Drop the existing view
DROP VIEW IF EXISTS "StoreSubmission";
-- Recreate the view with the new fields
CREATE VIEW "StoreSubmission" AS
SELECT
sl.id AS listing_id,
sl."owningUserId" AS user_id,
slv."agentGraphId" AS agent_id,
slv.version AS agent_version,
sl.slug,
COALESCE(slv.name, '') AS name,
slv."subHeading" AS sub_heading,
slv.description,
slv."imageUrls" AS image_urls,
slv."submittedAt" AS date_submitted,
slv."submissionStatus" AS status,
COALESCE(ar.run_count, 0::bigint) AS runs,
COALESCE(avg(sr.score::numeric), 0.0)::double precision AS rating,
slv.id AS store_listing_version_id,
slv."reviewerId" AS reviewer_id,
slv."reviewComments" AS review_comments,
slv."internalComments" AS internal_comments,
slv."reviewedAt" AS reviewed_at,
slv."changesSummary" AS changes_summary,
-- Add the two new fields:
slv."videoUrl" AS video_url,
slv.categories
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "AgentGraphExecution"."agentGraphId", count(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "AgentGraphExecution"."agentGraphId"
) ar ON ar."agentGraphId" = slv."agentGraphId"
WHERE sl."isDeleted" = false
GROUP BY sl.id, sl."owningUserId", slv.id, slv."agentGraphId", slv.version, sl.slug, slv.name,
slv."subHeading", slv.description, slv."imageUrls", slv."submittedAt",
slv."submissionStatus", slv."reviewerId", slv."reviewComments", slv."internalComments",
slv."reviewedAt", slv."changesSummary", slv."videoUrl", slv.categories, ar.run_count;

View File

@@ -659,6 +659,8 @@ view StoreSubmission {
internal_comments String?
reviewed_at DateTime?
changes_summary String?
video_url String?
categories String[]
// Index or unique are not applied to views
}

View File

@@ -20,7 +20,11 @@
"review_comments": null,
"internal_comments": null,
"reviewed_at": null,
"changes_summary": null
"changes_summary": null,
"video_url": "test.mp4",
"categories": [
"test-category"
]
}
],
"pagination": {

View File

@@ -39,20 +39,20 @@ faker = Faker()
# Constants for data generation limits (reduced for E2E tests)
NUM_USERS = 10
NUM_AGENT_BLOCKS = 20
MIN_GRAPHS_PER_USER = 10
MAX_GRAPHS_PER_USER = 10
MIN_NODES_PER_GRAPH = 2
MAX_NODES_PER_GRAPH = 4
MIN_PRESETS_PER_USER = 1
MAX_PRESETS_PER_USER = 2
MIN_AGENTS_PER_USER = 10
MAX_AGENTS_PER_USER = 10
MIN_EXECUTIONS_PER_GRAPH = 1
MAX_EXECUTIONS_PER_GRAPH = 5
MIN_REVIEWS_PER_VERSION = 1
MAX_REVIEWS_PER_VERSION = 3
NUM_USERS = 15
NUM_AGENT_BLOCKS = 30
MIN_GRAPHS_PER_USER = 15
MAX_GRAPHS_PER_USER = 15
MIN_NODES_PER_GRAPH = 3
MAX_NODES_PER_GRAPH = 6
MIN_PRESETS_PER_USER = 2
MAX_PRESETS_PER_USER = 3
MIN_AGENTS_PER_USER = 15
MAX_AGENTS_PER_USER = 15
MIN_EXECUTIONS_PER_GRAPH = 2
MAX_EXECUTIONS_PER_GRAPH = 8
MIN_REVIEWS_PER_VERSION = 2
MAX_REVIEWS_PER_VERSION = 5
def get_image():
@@ -76,6 +76,23 @@ def get_video_url():
return f"https://www.youtube.com/watch?v={video_id}"
def get_category():
"""Generate a random category from the predefined list."""
categories = [
"productivity",
"writing",
"development",
"data",
"marketing",
"research",
"creative",
"business",
"personal",
"other",
]
return random.choice(categories)
class TestDataCreator:
"""Creates test data using API functions for E2E tests."""
@@ -559,24 +576,37 @@ class TestDataCreator:
submissions.append(test_submission.model_dump())
print("✅ Created special test store submission for test123@gmail.com")
# Auto-approve the test submission
# Randomly approve, reject, or leave pending the test submission
if test_submission.store_listing_version_id:
approved_submission = await review_store_submission(
store_listing_version_id=test_submission.store_listing_version_id,
is_approved=True,
external_comments="Test submission approved",
internal_comments="Auto-approved test submission",
reviewer_id=test_user["id"],
)
approved_submissions.append(approved_submission.model_dump())
print("✅ Approved test store submission")
random_value = random.random()
if random_value < 0.4: # 40% chance to approve
approved_submission = await review_store_submission(
store_listing_version_id=test_submission.store_listing_version_id,
is_approved=True,
external_comments="Test submission approved",
internal_comments="Auto-approved test submission",
reviewer_id=test_user["id"],
)
approved_submissions.append(approved_submission.model_dump())
print("✅ Approved test store submission")
# Mark test submission as featured
await prisma.storelistingversion.update(
where={"id": test_submission.store_listing_version_id},
data={"isFeatured": True},
)
print("🌟 Marked test agent as FEATURED")
# Mark approved submission as featured
await prisma.storelistingversion.update(
where={"id": test_submission.store_listing_version_id},
data={"isFeatured": True},
)
print("🌟 Marked test agent as FEATURED")
elif random_value < 0.7: # 30% chance to reject (40% to 70%)
await review_store_submission(
store_listing_version_id=test_submission.store_listing_version_id,
is_approved=False,
external_comments="Test submission rejected - needs improvements",
internal_comments="Auto-rejected test submission for E2E testing",
reviewer_id=test_user["id"],
)
print("❌ Rejected test store submission")
else: # 30% chance to leave pending (70% to 100%)
print("⏳ Left test submission pending for review")
except Exception as e:
print(f"Error creating test store submission: {e}")
@@ -617,58 +647,87 @@ class TestDataCreator:
video_url=get_video_url() if random.random() < 0.3 else None,
image_urls=[get_image() for _ in range(3)],
description=faker.text(),
categories=[faker.word() for _ in range(3)],
categories=[
get_category()
], # Single category from predefined list
changes_summary="Initial E2E test submission",
)
submissions.append(submission.model_dump())
print(f"✅ Created store submission: {submission.name}")
# Approve the submission so it appears in the store
# Randomly approve, reject, or leave pending the submission
if submission.store_listing_version_id:
try:
# Pick a random user as the reviewer (admin)
reviewer_id = random.choice(self.users)["id"]
random_value = random.random()
if random_value < 0.4: # 40% chance to approve
try:
# Pick a random user as the reviewer (admin)
reviewer_id = random.choice(self.users)["id"]
approved_submission = await review_store_submission(
store_listing_version_id=submission.store_listing_version_id,
is_approved=True,
external_comments="Auto-approved for E2E testing",
internal_comments="Automatically approved by E2E test data script",
reviewer_id=reviewer_id,
)
approved_submissions.append(
approved_submission.model_dump()
)
print(f"✅ Approved store submission: {submission.name}")
approved_submission = await review_store_submission(
store_listing_version_id=submission.store_listing_version_id,
is_approved=True,
external_comments="Auto-approved for E2E testing",
internal_comments="Automatically approved by E2E test data script",
reviewer_id=reviewer_id,
)
approved_submissions.append(
approved_submission.model_dump()
)
print(
f"✅ Approved store submission: {submission.name}"
)
# Mark some agents as featured during creation (30% chance)
# More likely for creators and first submissions
is_creator = user["id"] in [
p.get("userId") for p in self.profiles
]
feature_chance = (
0.5 if is_creator else 0.2
) # 50% for creators, 20% for others
# Mark some agents as featured during creation (30% chance)
# More likely for creators and first submissions
is_creator = user["id"] in [
p.get("userId") for p in self.profiles
]
feature_chance = (
0.5 if is_creator else 0.2
) # 50% for creators, 20% for others
if random.random() < feature_chance:
try:
await prisma.storelistingversion.update(
where={
"id": submission.store_listing_version_id
},
data={"isFeatured": True},
)
print(
f"🌟 Marked agent as FEATURED: {submission.name}"
)
except Exception as e:
print(
f"Warning: Could not mark submission as featured: {e}"
)
if random.random() < feature_chance:
try:
await prisma.storelistingversion.update(
where={
"id": submission.store_listing_version_id
},
data={"isFeatured": True},
)
print(
f"🌟 Marked agent as FEATURED: {submission.name}"
)
except Exception as e:
print(
f"Warning: Could not mark submission as featured: {e}"
)
except Exception as e:
except Exception as e:
print(
f"Warning: Could not approve submission {submission.name}: {e}"
)
elif random_value < 0.7: # 30% chance to reject (40% to 70%)
try:
# Pick a random user as the reviewer (admin)
reviewer_id = random.choice(self.users)["id"]
await review_store_submission(
store_listing_version_id=submission.store_listing_version_id,
is_approved=False,
external_comments="Submission rejected - needs improvements",
internal_comments="Automatically rejected by E2E test data script",
reviewer_id=reviewer_id,
)
print(
f"❌ Rejected store submission: {submission.name}"
)
except Exception as e:
print(
f"Warning: Could not reject submission {submission.name}: {e}"
)
else: # 30% chance to leave pending (70% to 100%)
print(
f"Warning: Could not approve submission {submission.name}: {e}"
f"⏳ Left submission pending for review: {submission.name}"
)
except Exception as e:

View File

@@ -7,6 +7,7 @@ import {
AgentTableRow,
AgentTableRowProps,
} from "../AgentTableRow/AgentTableRow";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
export interface AgentTableProps {
agents: Omit<
@@ -15,15 +16,23 @@ export interface AgentTableProps {
| "selectedAgents"
| "onViewSubmission"
| "onDeleteSubmission"
| "onEditSubmission"
>[];
onViewSubmission: (submission: StoreSubmission) => void;
onDeleteSubmission: (submission_id: string) => void;
onEditSubmission: (
submission: StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
},
) => void;
}
export const AgentTable: React.FC<AgentTableProps> = ({
agents,
onViewSubmission,
onDeleteSubmission,
onEditSubmission,
}) => {
return (
<div className="w-full" data-testid="agent-table">
@@ -62,6 +71,7 @@ export const AgentTable: React.FC<AgentTableProps> = ({
{...agent}
onViewSubmission={onViewSubmission}
onDeleteSubmission={onDeleteSubmission}
onEditSubmission={onEditSubmission}
/>
<div className="block md:hidden">
<AgentTableCard

View File

@@ -3,8 +3,8 @@
import Image from "next/image";
import { IconStarFilled, IconMore } from "@/components/ui/icons";
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import { Status, StatusType } from "@/components/agptui/Status";
import { SubmissionStatus } from "@/app/api/__generated__/models/submissionStatus";
import { Status } from "@/components/agptui/Status";
export interface AgentTableCardProps {
agent_id: string;
@@ -14,7 +14,7 @@ export interface AgentTableCardProps {
description: string;
imageSrc: string[];
dateSubmitted: string;
status: StatusType;
status: SubmissionStatus;
runs: number;
rating: number;
id: number;
@@ -44,8 +44,7 @@ export const AgentTableCard = ({
description,
image_urls: imageSrc,
date_submitted: dateSubmitted,
// SafeCast: status is a string from the API...
status: status.toUpperCase() as SubmissionStatus,
status: status,
runs,
rating,
});

View File

@@ -4,7 +4,7 @@ import Image from "next/image";
import { Text } from "@/components/atoms/Text/Text";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { Status, StatusType } from "@/components/agptui/Status";
import { Status } from "@/components/agptui/Status";
import { useAgentTableRow } from "./useAgentTableRow";
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import {
@@ -13,7 +13,10 @@ import {
ImageBroken,
Star,
Trash,
PencilSimple,
} from "@phosphor-icons/react/dist/ssr";
import { SubmissionStatus } from "@/app/api/__generated__/models/submissionStatus";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
export interface AgentTableRowProps {
agent_id: string;
@@ -23,13 +26,22 @@ export interface AgentTableRowProps {
description: string;
imageSrc: string[];
date_submitted: string;
status: StatusType;
status: SubmissionStatus;
runs: number;
rating: number;
dateSubmitted: string;
id: number;
video_url?: string;
categories?: string[];
store_listing_version_id?: string;
onViewSubmission: (submission: StoreSubmission) => void;
onDeleteSubmission: (submission_id: string) => void;
onEditSubmission: (
submission: StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
},
) => void;
}
export const AgentTableRow = ({
@@ -44,13 +56,18 @@ export const AgentTableRow = ({
runs,
rating,
id,
video_url,
categories,
store_listing_version_id,
onViewSubmission,
onDeleteSubmission,
onEditSubmission,
}: AgentTableRowProps) => {
const { handleView, handleDelete } = useAgentTableRow({
const { handleView, handleDelete, handleEdit } = useAgentTableRow({
id,
onViewSubmission,
onDeleteSubmission,
onEditSubmission,
agent_id,
agent_version,
agentName,
@@ -58,16 +75,23 @@ export const AgentTableRow = ({
description,
imageSrc,
dateSubmitted,
status: status.toUpperCase(),
status,
runs,
rating,
video_url,
categories,
store_listing_version_id,
});
// Determine if we should show Edit or View button
const canEdit =
status === SubmissionStatus.APPROVED || status === SubmissionStatus.PENDING;
return (
<div
data-testid="agent-table-row"
data-agent-id={agent_id}
data-agent-name={agentName}
data-submission-id={store_listing_version_id}
className="hidden items-center border-b border-neutral-300 px-4 py-4 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 md:flex"
>
<div className="grid w-full grid-cols-[minmax(400px,1fr),180px,140px,100px,100px,40px] items-center gap-4">
@@ -140,13 +164,23 @@ export const AgentTableRow = ({
<DotsThreeVerticalIcon className="h-5 w-5 text-neutral-800" />
</DropdownMenu.Trigger>
<DropdownMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<DropdownMenu.Item
onSelect={handleView}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Eye className="mr-2 h-4 w-4 dark:text-gray-100" />
<span className="dark:text-gray-100">View</span>
</DropdownMenu.Item>
{canEdit ? (
<DropdownMenu.Item
onSelect={handleEdit}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<PencilSimple className="mr-2 h-4 w-4 dark:text-gray-100" />
<span className="dark:text-gray-100">Edit</span>
</DropdownMenu.Item>
) : (
<DropdownMenu.Item
onSelect={handleView}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Eye className="mr-2 h-4 w-4 dark:text-gray-100" />
<span className="dark:text-gray-100">View</span>
</DropdownMenu.Item>
)}
<DropdownMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<DropdownMenu.Item
onSelect={handleDelete}

View File

@@ -1,10 +1,17 @@
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
import { SubmissionStatus } from "@/app/api/__generated__/models/submissionStatus";
interface useAgentTableRowProps {
id: number;
onViewSubmission: (submission: StoreSubmission) => void;
onDeleteSubmission: (submission_id: string) => void;
onEditSubmission: (
submission: StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
},
) => void;
agent_id: string;
agent_version: number;
agentName: string;
@@ -12,14 +19,18 @@ interface useAgentTableRowProps {
description: string;
imageSrc: string[];
dateSubmitted: string;
status: string;
status: SubmissionStatus;
runs: number;
rating: number;
video_url?: string;
categories?: string[];
store_listing_version_id?: string;
}
export const useAgentTableRow = ({
onViewSubmission,
onDeleteSubmission,
onEditSubmission,
agent_id,
agent_version,
agentName,
@@ -30,6 +41,9 @@ export const useAgentTableRow = ({
status,
runs,
rating,
video_url,
categories,
store_listing_version_id,
}: useAgentTableRowProps) => {
const handleView = () => {
onViewSubmission({
@@ -41,16 +55,32 @@ export const useAgentTableRow = ({
description,
image_urls: imageSrc,
date_submitted: dateSubmitted,
// SafeCast: status is a string from the API...
status: status.toUpperCase() as SubmissionStatus,
status: status,
runs,
rating,
video_url,
categories,
store_listing_version_id,
} satisfies StoreSubmission);
};
const handleEdit = () => {
onEditSubmission({
name: agentName,
sub_heading,
description,
image_urls: imageSrc,
video_url,
categories,
changes_summary: "Update Submission",
store_listing_version_id,
agent_id,
});
};
const handleDelete = () => {
onDeleteSubmission(agent_id);
};
return { handleView, handleDelete };
return { handleView, handleDelete, handleEdit };
};

View File

@@ -1,8 +1,8 @@
import { useMainDashboardPage } from "./useMainDashboardPage";
import { Separator } from "@/components/ui/separator";
import { AgentTable } from "../AgentTable/AgentTable";
import { StatusType } from "@/components/agptui/Status";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { EditAgentModal } from "@/components/contextual/EditAgentModal/EditAgentModal";
import { Button } from "@/components/atoms/Button/Button";
import { EmptySubmissions } from "./components/EmptySubmissions";
import { SubmissionLoadError } from "./components/SumbmissionLoadError";
@@ -13,9 +13,13 @@ export const MainDashboardPage = () => {
const {
onDeleteSubmission,
onViewSubmission,
onEditSubmission,
onEditSuccess,
onEditClose,
onOpenSubmitModal,
onPublishStateChange,
publishState,
editState,
// API data
submissions,
isLoading,
@@ -89,17 +93,30 @@ export const MainDashboardPage = () => {
dateSubmitted: new Date(
submission.date_submitted,
).toLocaleDateString(),
status: submission.status.toLowerCase() as StatusType,
status: submission.status,
runs: submission.runs,
rating: submission.rating,
video_url: submission.video_url || undefined,
categories: submission.categories,
slug: submission.slug,
store_listing_version_id:
submission.store_listing_version_id || undefined,
}))}
onViewSubmission={onViewSubmission}
onDeleteSubmission={onDeleteSubmission}
onEditSubmission={onEditSubmission}
/>
) : (
<EmptySubmissions />
)}
</div>
<EditAgentModal
isOpen={editState.isOpen}
onClose={onEditClose}
submission={editState.submission}
onSuccess={onEditSuccess}
/>
</main>
);
};

View File

@@ -4,10 +4,12 @@ import {
useGetV2ListMySubmissions,
} from "@/app/api/__generated__/endpoints/store/store";
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
import { StoreSubmissionsResponse } from "@/app/api/__generated__/models/storeSubmissionsResponse";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useState } from "react";
import * as Sentry from "@sentry/nextjs";
type PublishStep = "select" | "info" | "review";
@@ -17,6 +19,16 @@ type PublishState = {
submissionData: StoreSubmission | null;
};
type EditState = {
isOpen: boolean;
submission:
| (StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
})
| null;
};
export const useMainDashboardPage = () => {
const queryClient = getQueryClient();
@@ -28,6 +40,11 @@ export const useMainDashboardPage = () => {
submissionData: null,
});
const [editState, setEditState] = useState<EditState>({
isOpen: false,
submission: null,
});
const { mutateAsync: deleteSubmission } = useDeleteV2DeleteStoreSubmission({
mutation: {
onSuccess: () => {
@@ -59,6 +76,43 @@ export const useMainDashboardPage = () => {
});
};
const onEditSubmission = (
submission: StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
},
) => {
setEditState({
isOpen: true,
submission,
});
};
const onEditSuccess = async (submission: StoreSubmission) => {
try {
if (!submission.store_listing_version_id) {
Sentry.captureException(
new Error("No store listing version ID found for submission"),
);
return;
}
setEditState({
isOpen: false,
submission: null,
});
} catch (error) {
Sentry.captureException(error);
}
};
const onEditClose = () => {
setEditState({
isOpen: false,
submission: null,
});
};
const onDeleteSubmission = async (submission_id: string) => {
await deleteSubmission({
submissionId: submission_id,
@@ -83,7 +137,11 @@ export const useMainDashboardPage = () => {
onPublishStateChange,
onDeleteSubmission,
onViewSubmission,
onEditSubmission,
onEditSuccess,
onEditClose,
publishState,
editState,
// API data
submissions,
isLoading: !isSuccess,

View File

@@ -0,0 +1,19 @@
/**
* Generated by orval v7.11.2 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
import type { StoreSubmissionEditRequestVideoUrl } from './storeSubmissionEditRequestVideoUrl';
import type { StoreSubmissionEditRequestChangesSummary } from './storeSubmissionEditRequestChangesSummary';
export interface StoreSubmissionEditRequest {
name: string;
sub_heading: string;
video_url?: StoreSubmissionEditRequestVideoUrl;
image_urls?: string[];
description?: string;
categories?: string[];
changes_summary?: StoreSubmissionEditRequestChangesSummary;
}

View File

@@ -2624,6 +2624,50 @@
}
}
},
"/api/store/submissions/{store_listing_version_id}": {
"put": {
"tags": ["v2", "store", "private"],
"summary": "Edit store submission",
"description": "Edit an existing store listing submission.\n\nArgs:\n store_listing_version_id (str): ID of the store listing version to edit\n submission_request (StoreSubmissionRequest): The updated submission details\n user_id (str): ID of the authenticated user editing the listing\n\nReturns:\n StoreSubmission: The updated store submission\n\nRaises:\n HTTPException: If there is an error editing the submission",
"operationId": "putV2Edit store submission",
"parameters": [
{
"name": "store_listing_version_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Store Listing Version Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreSubmissionEditRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/StoreSubmission" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/submissions/media": {
"post": {
"tags": ["v2", "store", "private"],
@@ -6299,6 +6343,16 @@
"changes_summary": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Changes Summary"
},
"video_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Video Url"
},
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories",
"default": []
}
},
"type": "object",
@@ -6317,6 +6371,40 @@
],
"title": "StoreSubmission"
},
"StoreSubmissionEditRequest": {
"properties": {
"name": { "type": "string", "title": "Name" },
"sub_heading": { "type": "string", "title": "Sub Heading" },
"video_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Video Url"
},
"image_urls": {
"items": { "type": "string" },
"type": "array",
"title": "Image Urls",
"default": []
},
"description": {
"type": "string",
"title": "Description",
"default": ""
},
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories",
"default": []
},
"changes_summary": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Changes Summary"
}
},
"type": "object",
"required": ["name", "sub_heading"],
"title": "StoreSubmissionEditRequest"
},
"StoreSubmissionRequest": {
"properties": {
"agent_id": { "type": "string", "title": "Agent Id" },

View File

@@ -1,13 +1,12 @@
import { SubmissionStatus } from "@/app/api/__generated__/models/submissionStatus";
import * as React from "react";
export type StatusType = "draft" | "awaiting_review" | "approved" | "rejected";
interface StatusProps {
status: StatusType;
status: SubmissionStatus;
}
const statusConfig: Record<
StatusType,
SubmissionStatus,
{
bgColor: string;
dotColor: string;
@@ -16,28 +15,28 @@ const statusConfig: Record<
darkDotColor: string;
}
> = {
draft: {
[SubmissionStatus.DRAFT]: {
bgColor: "bg-blue-50",
dotColor: "bg-blue-500",
text: "Draft",
darkBgColor: "dark:bg-blue-900",
darkDotColor: "dark:bg-blue-300",
},
awaiting_review: {
[SubmissionStatus.PENDING]: {
bgColor: "bg-amber-50",
dotColor: "bg-amber-500",
text: "Awaiting review",
darkBgColor: "dark:bg-amber-900",
darkDotColor: "dark:bg-amber-300",
},
approved: {
[SubmissionStatus.APPROVED]: {
bgColor: "bg-green-50",
dotColor: "bg-green-500",
text: "Approved",
darkBgColor: "dark:bg-green-900",
darkDotColor: "dark:bg-green-300",
},
rejected: {
[SubmissionStatus.REJECTED]: {
bgColor: "bg-red-50",
dotColor: "bg-red-500",
text: "Rejected",
@@ -53,9 +52,9 @@ export const Status: React.FC<StatusProps> = ({ status }) => {
* Valid values: 'draft', 'awaiting_review', 'approved', 'rejected'
*/
if (!status) {
return <Status status="awaiting_review" />;
return <Status status={SubmissionStatus.PENDING} />;
} else if (!statusConfig[status]) {
return <Status status="awaiting_review" />;
return <Status status={SubmissionStatus.PENDING} />;
}
const config = statusConfig[status];

View File

@@ -0,0 +1,51 @@
"use client";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import { EditAgentForm } from "./components/EditAgentForm";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
export interface EditAgentModalProps {
isOpen: boolean;
onClose: () => void;
submission:
| (StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
})
| null;
onSuccess: (submission: StoreSubmission) => void;
}
export function EditAgentModal({
isOpen,
onClose,
submission,
onSuccess,
}: EditAgentModalProps) {
if (!submission) return null;
return (
<Dialog
styling={{
maxWidth: "45rem",
}}
controlled={{
isOpen,
set: (isOpen) => {
if (!isOpen) onClose();
},
}}
>
<Dialog.Content>
<div data-testid="edit-agent-modal">
<EditAgentForm
submission={submission}
onClose={onClose}
onSuccess={onSuccess}
/>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import * as React from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Form, FormField } from "@/components/ui/form";
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import { ThumbnailImages } from "../../PublishAgentModal/components/AgentInfoStep/components/ThumbnailImages";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
import { StepHeader } from "../../PublishAgentModal/components/StepHeader";
import { useEditAgentForm } from "./useEditAgentForm";
interface EditAgentFormProps {
submission: StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
};
onClose: () => void;
onSuccess: (submission: StoreSubmission) => void;
}
export function EditAgentForm({
submission,
onClose,
onSuccess,
}: EditAgentFormProps) {
const {
form,
categoryOptions,
isSubmitting,
handleFormSubmit,
handleImagesChange,
} = useEditAgentForm({ submission, onSuccess });
return (
<div className="mx-auto flex w-full flex-col rounded-3xl">
<StepHeader title="Edit Agent" description="Update your agent details" />
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="flex-grow overflow-y-auto p-6"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<Input
id={field.name}
label="Title"
type="text"
placeholder="Agent name"
error={form.formState.errors.title?.message}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="subheader"
render={({ field }) => (
<Input
id={field.name}
label="Subheader"
type="text"
placeholder="A tagline for your agent"
error={form.formState.errors.subheader?.message}
{...field}
/>
)}
/>
<ThumbnailImages
agentId={submission.agent_id}
onImagesChange={handleImagesChange}
initialImages={submission.image_urls || []}
initialSelectedImage={submission.image_urls?.[0] || null}
errorMessage={form.formState.errors.root?.message}
/>
<FormField
control={form.control}
name="youtubeLink"
render={({ field }) => (
<Input
id={field.name}
label="YouTube video link"
type="url"
placeholder="Paste a video link here"
error={form.formState.errors.youtubeLink?.message}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => {
console.log("Edit Category field value:", field.value);
return (
<Select
id={field.name}
label="Category"
placeholder="Select a category for your agent"
value={field.value}
onValueChange={field.onChange}
error={form.formState.errors.category?.message}
options={categoryOptions}
/>
);
}}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<Input
id={field.name}
label="Description"
type="textarea"
placeholder="Describe your agent and what it does"
error={form.formState.errors.description?.message}
{...field}
/>
)}
/>
<FormField
control={form.control}
name="changes_summary"
render={({ field }) => (
<Input
id={field.name}
label="Changes Summary"
type="text"
placeholder="Briefly describe what you changed"
error={form.formState.errors.changes_summary?.message}
{...field}
/>
)}
/>
<div className="flex justify-between gap-4 pt-6">
<Button
type="button"
onClick={onClose}
variant="secondary"
className="w-full"
>
Cancel
</Button>
<Button
type="submit"
className="w-full"
disabled={
Object.keys(form.formState.errors).length > 0 || isSubmitting
}
loading={isSubmitting}
>
Update submission
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import * as Sentry from "@sentry/nextjs";
import {
getGetV2ListMySubmissionsQueryKey,
usePutV2EditStoreSubmission,
} from "@/app/api/__generated__/endpoints/store/store";
import { StoreSubmission } from "@/app/api/__generated__/models/storeSubmission";
import { StoreSubmissionEditRequest } from "@/app/api/__generated__/models/storeSubmissionEditRequest";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { useForm } from "react-hook-form";
import z from "zod";
interface useEditAgentFormProps {
submission: StoreSubmissionEditRequest & {
store_listing_version_id: string | undefined;
agent_id: string;
};
onSuccess: (submission: StoreSubmission) => void;
}
export const useEditAgentForm = ({
submission,
onSuccess,
}: useEditAgentFormProps) => {
const editAgentSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters"),
subheader: z
.string()
.min(1, "Subheader is required")
.max(200, "Subheader must be less than 200 characters"),
youtubeLink: z
.string()
.optional()
.refine((val) => {
if (!val) return true;
try {
const url = new URL(val);
const allowedHosts = [
"youtube.com",
"www.youtube.com",
"youtu.be",
"www.youtu.be",
];
return allowedHosts.includes(url.hostname);
} catch {
return false;
}
}, "Please enter a valid YouTube URL"),
category: z.string().min(1, "Category is required"),
description: z
.string()
.min(1, "Description is required")
.max(1000, "Description must be less than 1000 characters"),
changes_summary: z
.string()
.min(1, "Changes summary is required")
.max(200, "Changes summary must be less than 200 characters"),
});
type EditAgentFormData = z.infer<typeof editAgentSchema>;
const [images, setImages] = React.useState<string[]>(
submission.image_urls || [],
);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const { mutateAsync: editSubmission } = usePutV2EditStoreSubmission({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListMySubmissionsQueryKey(),
});
},
},
});
const queryClient = useQueryClient();
const { toast } = useToast();
const form = useForm<EditAgentFormData>({
resolver: zodResolver(editAgentSchema),
defaultValues: {
title: submission.name,
subheader: submission.sub_heading,
youtubeLink: submission.video_url || "",
category: submission.categories?.[0] || "",
description: submission.description,
changes_summary: submission.changes_summary || "",
},
});
const categoryOptions = [
{ value: "productivity", label: "Productivity" },
{ value: "writing", label: "Writing & Content" },
{ value: "development", label: "Development" },
{ value: "data", label: "Data & Analytics" },
{ value: "marketing", label: "Marketing & SEO" },
{ value: "research", label: "Research & Learning" },
{ value: "creative", label: "Creative & Design" },
{ value: "business", label: "Business & Finance" },
{ value: "personal", label: "Personal Assistant" },
{ value: "other", label: "Other" },
];
const handleImagesChange = React.useCallback((newImages: string[]) => {
setImages(newImages);
}, []);
async function handleFormSubmit(data: EditAgentFormData) {
// Validate that at least one image is present
if (images.length === 0) {
form.setError("root", {
type: "manual",
message: "At least one image is required",
});
return;
}
const categories = data.category ? [data.category] : [];
const filteredCategories = categories.filter(Boolean);
setIsSubmitting(true);
try {
const response = await editSubmission({
storeListingVersionId: submission.store_listing_version_id!,
data: {
name: data.title,
sub_heading: data.subheader,
description: data.description,
image_urls: images,
video_url: data.youtubeLink || "",
categories: filteredCategories,
changes_summary: data.changes_summary,
},
});
// Extract the StoreSubmission from the response
if (response.status === 200 && response.data) {
onSuccess(response.data);
} else {
throw new Error("Failed to update submission");
}
} catch (error) {
Sentry.captureException(error);
toast({
title: "Edit Agent Error",
description:
"An error occurred while editing the agent. Please try again.",
duration: 3000,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
}
return {
form,
isSubmitting,
handleFormSubmit,
handleImagesChange,
categoryOptions,
};
};

View File

@@ -135,3 +135,105 @@ test("edit action is unavailable for rejected agents (view only)", async ({
await expect(page.getByRole("menuitem", { name: "View" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Edit" })).toHaveCount(0);
});
test("editing an approved agent creates a new pending submission", async ({
page,
}) => {
await page.goto("/profile/dashboard");
const agentTable = page.getByTestId("agent-table");
await expect(agentTable).toBeVisible();
const rows = agentTable.getByTestId("agent-table-row");
const approvedRow = rows.filter({ hasText: "Approved" }).first();
if (!(await approvedRow.count())) {
console.log("No approved agents available; skipping approved edit test.");
return;
}
const beforeCount = await rows.count();
await approvedRow.scrollIntoViewIfNeeded();
const actionsButton = approvedRow.getByTestId("agent-table-row-actions");
await actionsButton.waitFor({ state: "visible", timeout: 10000 });
await actionsButton.scrollIntoViewIfNeeded();
await actionsButton.click();
const editButton = page.getByRole("menuitem", { name: "Edit" });
await expect(editButton).toBeVisible();
await editButton.click();
const editModal = page.getByTestId("edit-agent-modal");
await expect(editModal).toBeVisible();
const newTitle = `E2E Edit Approved ${Date.now()}`;
await page.getByRole("textbox", { name: "Title" }).fill(newTitle);
await page
.getByRole("textbox", { name: "Changes Summary" })
.fill("E2E change - approved -> new pending submission");
await page.getByRole("button", { name: "Update submission" }).click();
await expect(editModal).not.toBeVisible();
// A new submission should appear with pending state
await expect(async () => {
const afterCount = await rows.count();
expect(afterCount).toBeGreaterThan(beforeCount);
}).toPass();
const newRow = rows.filter({ hasText: newTitle }).first();
await expect(newRow).toBeVisible();
await expect(newRow).toContainText(/Awaiting review/);
});
test("editing a pending agent updates the same submission in place", async ({
page,
}) => {
await page.goto("/profile/dashboard");
const agentTable = page.getByTestId("agent-table");
await expect(agentTable).toBeVisible();
const rows = agentTable.getByTestId("agent-table-row");
const pendingRow = rows.filter({ hasText: /Awaiting review/ }).first();
if (!(await pendingRow.count())) {
console.log("No pending agents available; skipping pending edit test.");
return;
}
const beforeCount = await rows.count();
await pendingRow.scrollIntoViewIfNeeded();
const actionsButton = pendingRow.getByTestId("agent-table-row-actions");
await actionsButton.waitFor({ state: "visible", timeout: 10000 });
await actionsButton.scrollIntoViewIfNeeded();
await actionsButton.click();
const editButton = page.getByRole("menuitem", { name: "Edit" });
await expect(editButton).toBeVisible();
await editButton.click();
const editModal = page.getByTestId("edit-agent-modal");
await expect(editModal).toBeVisible();
const newTitle = `E2E Edit Pending ${Date.now()}`;
await page.getByRole("textbox", { name: "Title" }).fill(newTitle);
await page
.getByRole("textbox", { name: "Changes Summary" })
.fill("E2E change - pending -> same submission");
await page.getByRole("button", { name: "Update submission" }).click();
await expect(editModal).not.toBeVisible();
// Count should remain the same
await expect(async () => {
const afterCount = await rows.count();
expect(afterCount).toBe(beforeCount);
}).toPass();
const updatedRow = rows.filter({ hasText: newTitle }).first();
await expect(updatedRow).toBeVisible();
await expect(updatedRow).toContainText(/Awaiting review/);
});