Compare commits

...

6 Commits

Author SHA1 Message Date
Bentlybro
9b71c03d96 Add 'Back' button to tutorial popover
Introduces a 'Back' button to the blocks control popover in the tutorial, allowing users to navigate to the previous step.
2025-08-20 11:57:39 +01:00
Bentlybro
b20b00a441 prettier 2025-08-20 11:13:08 +01:00
Bentlybro
84810ce0af Add delay and safeguard for tutorial popover initialization
Introduces a short delay before starting the tutorial in FlowEditor to ensure component state is initialized, especially after redirects. Also adds a safeguard in the tutorial to keep the blocks popover pinned during the relevant step.
2025-08-20 11:05:37 +01:00
Abhimanyu Yadav
2610c4579f 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>
2025-08-20 02:49:29 +00:00
Abhimanyu Yadav
0c09b0c459 chore(api): remove launch darkly feature flags from api key endpoints (#10694)
Some API key endpoints have the Launch Darkly feature flag enabled,
while others don’t. To ensure consistency and remove the API key flag
from the Launch Darkly dashboard, I’m also removing it from the left
endpoints.

### 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] Everything is working fine locally
2025-08-19 17:10:43 +00:00
Abhimanyu Yadav
1105e6c0d2 tests(frontend): e2e tests for api key page (#10683)
I’ve added three tests for the API keys page:

- The test checks if the user is redirected to the login page when
they’re not authenticated.
- The test verifies that a new API key is created successfully.
- The test ensures that an existing API key can be revoked.

<img width="470" height="143" alt="Screenshot 2025-08-19 at 10 56 19 AM"
src="https://github.com/user-attachments/assets/d27bf736-61ec-435b-a6c4-820e4f3a5e2f"
/>

I’ve also removed the feature flag from the `delete_api_key` endpoint,
so we can use it on CI and in the local environment.

### Checklist 📋
- [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] tests are working perfectly locally.

---------

Co-authored-by: Ubbe <hi@ubbe.dev>
2025-08-19 16:04:15 +00:00
35 changed files with 1307 additions and 150 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

@@ -1071,7 +1071,6 @@ async def get_api_key(
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)
@feature_flag("api-keys-enabled")
async def delete_api_key(
key_id: str, user_id: Annotated[str, Depends(get_user_id)]
) -> Optional[APIKeyWithoutHash]:
@@ -1100,7 +1099,6 @@ async def delete_api_key(
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)
@feature_flag("api-keys-enabled")
async def suspend_key(
key_id: str, user_id: Annotated[str, Depends(get_user_id)]
) -> Optional[APIKeyWithoutHash]:
@@ -1126,7 +1124,6 @@ async def suspend_key(
tags=["api-keys"],
dependencies=[Depends(auth_middleware)],
)
@feature_flag("api-keys-enabled")
async def update_permissions(
key_id: str,
request: UpdatePermissionsRequest,

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

@@ -44,9 +44,9 @@ export function APIKeysSection() {
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableRow key={key.id} data-testid="api-key-row">
<TableCell>{key.name}</TableCell>
<TableCell>
<TableCell data-testid="api-key-id">
<div className="rounded-md border p-1 px-2 text-xs">
{`${key.prefix}******************${key.postfix}`}
</div>
@@ -76,7 +76,11 @@ export function APIKeysSection() {
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Button
data-testid="api-key-actions"
variant="ghost"
size="sm"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>

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

@@ -186,10 +186,16 @@ const FlowEditor: React.FC<{
storage.clean(Key.SHEPHERD_TOUR);
router.push(pathname);
} else if (!storage.get(Key.SHEPHERD_TOUR)) {
const emptyNodes = (forceRemove: boolean = false) =>
forceRemove ? (setNodes([]), setEdges([]), true) : nodes.length === 0;
startTutorial(emptyNodes, setPinBlocksPopover, setPinSavePopover);
storage.set(Key.SHEPHERD_TOUR, "yes");
// Add a small delay to ensure the component state is fully initialized
// This is especially important when resetTutorial=true caused a redirect
const timer = setTimeout(() => {
const emptyNodes = (forceRemove: boolean = false) =>
forceRemove ? (setNodes([]), setEdges([]), true) : nodes.length === 0;
startTutorial(emptyNodes, setPinBlocksPopover, setPinSavePopover);
storage.set(Key.SHEPHERD_TOUR, "yes");
}, 100);
return () => clearTimeout(timer);
}
}, [router, pathname, params, setEdges, setNodes, nodes.length]);

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

@@ -190,7 +190,12 @@ export const startTutorial = (
element: '[data-id="blocks-control-popover-content"]',
on: "right",
},
buttons: [],
buttons: [
{
text: "Back",
action: tour.back,
},
],
beforeShowPromise: () =>
waitForElement('[data-id="blocks-control-popover-content"]').then(() => {
disableOtherBlocks(
@@ -202,7 +207,11 @@ export const startTutorial = (
event: "click",
},
when: {
show: () => setPinBlocksPopover(true),
show: () => {
setPinBlocksPopover(true);
// Additional safeguard - ensure the popover stays pinned for this step
setTimeout(() => setPinBlocksPopover(true), 50);
},
hide: enableAllBlocks,
},
});

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/);
});

View File

@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test";
import { LoginPage } from "./pages/login.page";
import { TEST_CREDENTIALS } from "./credentials";
import { hasUrl } from "./utils/assertion";
import { getSelectors } from "./utils/selectors";
test.describe("API Keys Page", () => {
test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
await page.goto("/login");
await loginPage.login(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password);
await hasUrl(page, "/marketplace");
});
test("should redirect to login page when user is not authenticated", async ({
browser,
}) => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto("/profile/api_keys");
await hasUrl(page, "/login");
} finally {
await page.close();
await context.close();
}
});
test("should create a new API key successfully", async ({ page }) => {
const { getButton, getField } = getSelectors(page);
await page.goto("/profile/api_keys");
await getButton("Create Key").click();
await getField("Name").fill("Test Key");
await getButton("Create").click();
await expect(
page.getByText("AutoGPT Platform API Key Created"),
).toBeVisible();
await getButton("Close").first().click();
await expect(page.getByText("Test Key").first()).toBeVisible();
});
test("should revoke an existing API key", async ({ page }) => {
const { getRole, getId } = getSelectors(page);
await page.goto("/profile/api_keys");
const apiKeyRow = getId("api-key-row").first();
const apiKeyContent = await apiKeyRow
.getByTestId("api-key-id")
.textContent();
const apiKeyActions = apiKeyRow.getByTestId("api-key-actions").first();
await apiKeyActions.click();
await getRole("menuitem", "Revoke").click();
await expect(
page.getByText("AutoGPT Platform API key revoked successfully"),
).toBeVisible();
await expect(page.getByText(apiKeyContent!)).not.toBeVisible();
});
});