Fix the MQTT node, suggest message names, and drop Items
- MQTT nodes failed to build: the topic map was renamed to talk in ports, but __slots__ still declared the old name, so every MQTT node raised AttributeError. Building one of each node type is now a test, since __slots__ makes this failure invisible until someone places the node. - Port names offer the messages already in play: everything published is worth reading, and an input nobody provides yet is worth publishing. A message only connects when both ends spell it the same way, so choosing beats typing. - Adding a port focuses its name field. - Dragging onto an input that already reads something offers the extra port as well as the replacement — an MQTT or InfluxDB node usually wants both. - The template's Item model, its routes, screens and table are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
co-authored by
Claude Fable 5
parent
8c82549cf6
commit
01af7787c1
@@ -0,0 +1,40 @@
|
|||||||
|
"""Drop the item table
|
||||||
|
|
||||||
|
The template's example model. Flows are persisted as files in their own git
|
||||||
|
repository, so nothing in the app uses it any more.
|
||||||
|
|
||||||
|
Revision ID: b7c41d2f8a30
|
||||||
|
Revises: fe56fa70289e
|
||||||
|
Create Date: 2026-08-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel.sql.sqltypes
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "b7c41d2f8a30"
|
||||||
|
down_revision = "fe56fa70289e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.drop_table("item")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.create_table(
|
||||||
|
"item",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"title", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"description", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("owner_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import flows, items, login, private, secrets, users, utils
|
from app.api.routes import flows, login, private, secrets, users, utils
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(login.router)
|
api_router.include_router(login.router)
|
||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(utils.router)
|
api_router.include_router(utils.router)
|
||||||
api_router.include_router(items.router)
|
|
||||||
api_router.include_router(flows.router)
|
api_router.include_router(flows.router)
|
||||||
api_router.include_router(flows.ws_router)
|
api_router.include_router(flows.ws_router)
|
||||||
api_router.include_router(secrets.router)
|
api_router.include_router(secrets.router)
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from sqlmodel import col, func, select
|
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
|
||||||
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/items", tags=["items"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=ItemsPublic)
|
|
||||||
def read_items(
|
|
||||||
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Retrieve items.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if current_user.is_superuser:
|
|
||||||
count_statement = select(func.count()).select_from(Item)
|
|
||||||
count = session.exec(count_statement).one()
|
|
||||||
statement = (
|
|
||||||
select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit)
|
|
||||||
)
|
|
||||||
items = session.exec(statement).all()
|
|
||||||
else:
|
|
||||||
count_statement = (
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Item)
|
|
||||||
.where(Item.owner_id == current_user.id)
|
|
||||||
)
|
|
||||||
count = session.exec(count_statement).one()
|
|
||||||
statement = (
|
|
||||||
select(Item)
|
|
||||||
.where(Item.owner_id == current_user.id)
|
|
||||||
.order_by(col(Item.created_at).desc())
|
|
||||||
.offset(skip)
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
items = session.exec(statement).all()
|
|
||||||
|
|
||||||
return ItemsPublic(data=items, count=count)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=ItemPublic)
|
|
||||||
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
|
|
||||||
"""
|
|
||||||
Get item by ID.
|
|
||||||
"""
|
|
||||||
item = session.get(Item, id)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
|
||||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=ItemPublic)
|
|
||||||
def create_item(
|
|
||||||
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Create new item.
|
|
||||||
"""
|
|
||||||
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
|
|
||||||
session.add(item)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{id}", response_model=ItemPublic)
|
|
||||||
def update_item(
|
|
||||||
*,
|
|
||||||
session: SessionDep,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
id: uuid.UUID,
|
|
||||||
item_in: ItemUpdate,
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Update an item.
|
|
||||||
"""
|
|
||||||
item = session.get(Item, id)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
|
||||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
|
||||||
update_dict = item_in.model_dump(exclude_unset=True)
|
|
||||||
item.sqlmodel_update(update_dict)
|
|
||||||
session.add(item)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}")
|
|
||||||
def delete_item(
|
|
||||||
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
|
|
||||||
) -> Message:
|
|
||||||
"""
|
|
||||||
Delete an item.
|
|
||||||
"""
|
|
||||||
item = session.get(Item, id)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
|
||||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
|
||||||
session.delete(item)
|
|
||||||
session.commit()
|
|
||||||
return Message(message="Item deleted successfully")
|
|
||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import col, delete, func, select
|
from sqlmodel import col, func, select
|
||||||
|
|
||||||
from app import crud
|
from app import crud
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
@@ -13,7 +13,6 @@ from app.api.deps import (
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import get_password_hash, verify_password
|
from app.core.security import get_password_hash, verify_password
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Item,
|
|
||||||
Message,
|
Message,
|
||||||
UpdatePassword,
|
UpdatePassword,
|
||||||
User,
|
User,
|
||||||
@@ -224,8 +223,6 @@ def delete_user(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||||
)
|
)
|
||||||
statement = delete(Item).where(col(Item.owner_id) == user_id)
|
|
||||||
session.exec(statement)
|
|
||||||
session.delete(user)
|
session.delete(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Message(message="User deleted successfully")
|
return Message(message="User deleted successfully")
|
||||||
|
|||||||
+1
-10
@@ -1,10 +1,9 @@
|
|||||||
import uuid
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.security import get_password_hash, verify_password
|
from app.core.security import get_password_hash, verify_password
|
||||||
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
|
from app.models import User, UserCreate, UserUpdate
|
||||||
|
|
||||||
|
|
||||||
def create_user(*, session: Session, user_create: UserCreate) -> User:
|
def create_user(*, session: Session, user_create: UserCreate) -> User:
|
||||||
@@ -58,11 +57,3 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_user)
|
session.refresh(db_user)
|
||||||
return db_user
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
|
|
||||||
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
|
|
||||||
session.add(db_item)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(db_item)
|
|
||||||
return db_item
|
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ class MqttNode(Node):
|
|||||||
"qos",
|
"qos",
|
||||||
"retain",
|
"retain",
|
||||||
"keepalive",
|
"keepalive",
|
||||||
"_topic_to_messages",
|
"_topic_to_ports",
|
||||||
"_subscription_task",
|
"_subscription_task",
|
||||||
"_mqtt_client",
|
"_mqtt_client",
|
||||||
"_stop_event",
|
"_stop_event",
|
||||||
@@ -1001,7 +1001,7 @@ class MqttNode(Node):
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Started MQTT subscription for node '%s' to topics %s",
|
"Started MQTT subscription for node '%s' to topics %s",
|
||||||
self.name,
|
self.name,
|
||||||
list(self._topic_to_messages.keys()),
|
list(self._topic_to_ports.keys()),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stop_subscription(self) -> None:
|
async def stop_subscription(self) -> None:
|
||||||
@@ -1034,7 +1034,7 @@ class MqttNode(Node):
|
|||||||
Background loop that listens for MQTT messages and triggers the pipeline.
|
Background loop that listens for MQTT messages and triggers the pipeline.
|
||||||
|
|
||||||
Subscribes to all unique topics from the ``topics`` mapping and
|
Subscribes to all unique topics from the ``topics`` mapping and
|
||||||
uses the reverse lookup ``_topic_to_messages`` to route incoming
|
uses the reverse lookup ``_topic_to_ports`` to route incoming
|
||||||
payloads to the correct pipeline message names.
|
payloads to the correct pipeline message names.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -1052,7 +1052,7 @@ class MqttNode(Node):
|
|||||||
keepalive=self.keepalive,
|
keepalive=self.keepalive,
|
||||||
) as client:
|
) as client:
|
||||||
# Subscribe to every unique topic
|
# Subscribe to every unique topic
|
||||||
for topic in self._topic_to_messages:
|
for topic in self._topic_to_ports:
|
||||||
await client.subscribe(topic, qos=self.qos)
|
await client.subscribe(topic, qos=self.qos)
|
||||||
logger.info("[%s] Subscribed to %s", self.name, topic)
|
logger.info("[%s] Subscribed to %s", self.name, topic)
|
||||||
|
|
||||||
|
|||||||
+1
-43
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from pydantic import EmailStr
|
from pydantic import EmailStr
|
||||||
from sqlalchemy import DateTime
|
from sqlalchemy import DateTime
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
def get_datetime_utc() -> datetime:
|
def get_datetime_utc() -> datetime:
|
||||||
@@ -53,7 +53,6 @@ class User(UserBase, table=True):
|
|||||||
default_factory=get_datetime_utc,
|
default_factory=get_datetime_utc,
|
||||||
sa_type=DateTime(timezone=True), # type: ignore
|
sa_type=DateTime(timezone=True), # type: ignore
|
||||||
)
|
)
|
||||||
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
|
||||||
|
|
||||||
|
|
||||||
# Properties to return via API, id is always required
|
# Properties to return via API, id is always required
|
||||||
@@ -67,47 +66,6 @@ class UsersPublic(SQLModel):
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
# Shared properties
|
|
||||||
class ItemBase(SQLModel):
|
|
||||||
title: str = Field(min_length=1, max_length=255)
|
|
||||||
description: str | None = Field(default=None, max_length=255)
|
|
||||||
|
|
||||||
|
|
||||||
# Properties to receive on item creation
|
|
||||||
class ItemCreate(ItemBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Properties to receive on item update
|
|
||||||
class ItemUpdate(ItemBase):
|
|
||||||
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
# Database model, database table inferred from class name
|
|
||||||
class Item(ItemBase, table=True):
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default_factory=get_datetime_utc,
|
|
||||||
sa_type=DateTime(timezone=True), # type: ignore
|
|
||||||
)
|
|
||||||
owner_id: uuid.UUID = Field(
|
|
||||||
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
|
||||||
)
|
|
||||||
owner: User | None = Relationship(back_populates="items")
|
|
||||||
|
|
||||||
|
|
||||||
# Properties to return via API, id is always required
|
|
||||||
class ItemPublic(ItemBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
owner_id: uuid.UUID
|
|
||||||
created_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ItemsPublic(SQLModel):
|
|
||||||
data: list[ItemPublic]
|
|
||||||
count: int
|
|
||||||
|
|
||||||
|
|
||||||
# Generic message
|
# Generic message
|
||||||
class Message(SQLModel):
|
class Message(SQLModel):
|
||||||
message: str
|
message: str
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from tests.utils.item import create_random_item
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_item(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
data = {"title": "Foo", "description": "Fighters"}
|
|
||||||
response = client.post(
|
|
||||||
f"{settings.API_V1_STR}/items/",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
json=data,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["title"] == data["title"]
|
|
||||||
assert content["description"] == data["description"]
|
|
||||||
assert "id" in content
|
|
||||||
assert "owner_id" in content
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_item(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
|
||||||
) -> None:
|
|
||||||
item = create_random_item(db)
|
|
||||||
response = client.get(
|
|
||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["title"] == item.title
|
|
||||||
assert content["description"] == item.description
|
|
||||||
assert content["id"] == str(item.id)
|
|
||||||
assert content["owner_id"] == str(item.owner_id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_item_not_found(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
response = client.get(
|
|
||||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
content = response.json()
|
|
||||||
assert content["detail"] == "Item not found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_item_not_enough_permissions(
|
|
||||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
|
||||||
) -> None:
|
|
||||||
item = create_random_item(db)
|
|
||||||
response = client.get(
|
|
||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
|
||||||
headers=normal_user_token_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
content = response.json()
|
|
||||||
assert content["detail"] == "Not enough permissions"
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_items(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
|
||||||
) -> None:
|
|
||||||
create_random_item(db)
|
|
||||||
create_random_item(db)
|
|
||||||
response = client.get(
|
|
||||||
f"{settings.API_V1_STR}/items/",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert len(content["data"]) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_item(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
|
||||||
) -> None:
|
|
||||||
item = create_random_item(db)
|
|
||||||
data = {"title": "Updated title", "description": "Updated description"}
|
|
||||||
response = client.put(
|
|
||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
json=data,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["title"] == data["title"]
|
|
||||||
assert content["description"] == data["description"]
|
|
||||||
assert content["id"] == str(item.id)
|
|
||||||
assert content["owner_id"] == str(item.owner_id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_item_not_found(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
data = {"title": "Updated title", "description": "Updated description"}
|
|
||||||
response = client.put(
|
|
||||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
json=data,
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
content = response.json()
|
|
||||||
assert content["detail"] == "Item not found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_item_not_enough_permissions(
|
|
||||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
|
||||||
) -> None:
|
|
||||||
item = create_random_item(db)
|
|
||||||
data = {"title": "Updated title", "description": "Updated description"}
|
|
||||||
response = client.put(
|
|
||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
|
||||||
headers=normal_user_token_headers,
|
|
||||||
json=data,
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
content = response.json()
|
|
||||||
assert content["detail"] == "Not enough permissions"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_item(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
|
||||||
) -> None:
|
|
||||||
item = create_random_item(db)
|
|
||||||
response = client.delete(
|
|
||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["message"] == "Item deleted successfully"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_item_not_found(
|
|
||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
response = client.delete(
|
|
||||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
|
||||||
headers=superuser_token_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
content = response.json()
|
|
||||||
assert content["detail"] == "Item not found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_item_not_enough_permissions(
|
|
||||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
|
||||||
) -> None:
|
|
||||||
item = create_random_item(db)
|
|
||||||
response = client.delete(
|
|
||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
|
||||||
headers=normal_user_token_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
content = response.json()
|
|
||||||
assert content["detail"] == "Not enough permissions"
|
|
||||||
@@ -7,7 +7,7 @@ from sqlmodel import Session, delete
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import engine, init_db
|
from app.core.db import engine, init_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import Item, User
|
from app.models import User
|
||||||
from tests.utils.user import authentication_token_from_email
|
from tests.utils.user import authentication_token_from_email
|
||||||
from tests.utils.utils import get_superuser_token_headers
|
from tests.utils.utils import get_superuser_token_headers
|
||||||
|
|
||||||
@@ -26,8 +26,6 @@ def db() -> Generator[Session, None, None]:
|
|||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
init_db(session)
|
init_db(session)
|
||||||
yield session
|
yield session
|
||||||
statement = delete(Item)
|
|
||||||
session.execute(statement)
|
|
||||||
statement = delete(User)
|
statement = delete(User)
|
||||||
session.execute(statement)
|
session.execute(statement)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Every node type the editor offers must actually build.
|
||||||
|
|
||||||
|
The node classes use ``__slots__``, so an attribute the class forgot to declare
|
||||||
|
only fails when someone places that node. Building one of each catches it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.flow.controller import NODE_TYPES, node_type_info
|
||||||
|
from app.flow.messages import DType, MessageSpec
|
||||||
|
|
||||||
|
# Enough configuration for each type to construct; the ports decide the
|
||||||
|
# direction, so every type gets both an input and an output where it allows it.
|
||||||
|
FIXTURES: dict[str, dict] = {
|
||||||
|
"mqtt": {
|
||||||
|
"params": {"topic": "sensors/temp", "broker_host": "broker.local"},
|
||||||
|
"requires": [],
|
||||||
|
"provides": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"params": {"url": "/hook", "method": "POST"},
|
||||||
|
"requires": [],
|
||||||
|
"provides": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||||
|
},
|
||||||
|
"influxdb": {
|
||||||
|
"params": {
|
||||||
|
"url": "http://influx.local",
|
||||||
|
"token": "t",
|
||||||
|
"org": "o",
|
||||||
|
"bucket": "b",
|
||||||
|
},
|
||||||
|
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||||
|
"provides": [],
|
||||||
|
},
|
||||||
|
"delay": {
|
||||||
|
"params": {"interval": 5},
|
||||||
|
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||||
|
"provides": [MessageSpec(name="temp_out", dtype=DType.FLOAT)],
|
||||||
|
},
|
||||||
|
"mlp": {
|
||||||
|
"params": {"seed": 1},
|
||||||
|
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
|
||||||
|
"provides": [MessageSpec(name="score", dtype=DType.FLOAT)],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("type_name", sorted(FIXTURES))
|
||||||
|
def test_node_type_builds_and_qualifies(type_name: str):
|
||||||
|
fixture = FIXTURES[type_name]
|
||||||
|
node = NODE_TYPES[type_name].cls(
|
||||||
|
requires=fixture["requires"],
|
||||||
|
provides=fixture["provides"],
|
||||||
|
params=fixture["params"],
|
||||||
|
name=type_name,
|
||||||
|
)
|
||||||
|
node.assign_flow("house", type_name)
|
||||||
|
|
||||||
|
assert node.id == f"house.{type_name}"
|
||||||
|
for spec in [*node.input_ports, *node.output_ports]:
|
||||||
|
assert spec.name.startswith("house.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_routes_topics_by_port():
|
||||||
|
node = NODE_TYPES["mqtt"].cls(
|
||||||
|
provides=[
|
||||||
|
MessageSpec(name="temp", dtype=DType.FLOAT),
|
||||||
|
MessageSpec(name="humidity", dtype=DType.FLOAT),
|
||||||
|
],
|
||||||
|
params={"topic": {"temp": "house/temp", "humidity": "house/hum"}},
|
||||||
|
name="sensors",
|
||||||
|
)
|
||||||
|
node.assign_flow("house", "sensors")
|
||||||
|
|
||||||
|
# Qualifying the messages must not disturb the topic wiring.
|
||||||
|
assert node._topic_to_ports == {"house/temp": ["temp"], "house/hum": ["humidity"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_offered_type_has_a_fixture():
|
||||||
|
# A new node type without a fixture here would ship untested.
|
||||||
|
offered = {info.type for info in node_type_info() if info.type != "python"}
|
||||||
|
assert offered == set(FIXTURES)
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app import crud
|
|
||||||
from app.models import Item, ItemCreate
|
|
||||||
from tests.utils.user import create_random_user
|
|
||||||
from tests.utils.utils import random_lower_string
|
|
||||||
|
|
||||||
|
|
||||||
def create_random_item(db: Session) -> Item:
|
|
||||||
user = create_random_user(db)
|
|
||||||
owner_id = user.id
|
|
||||||
assert owner_id is not None
|
|
||||||
title = random_lower_string()
|
|
||||||
description = random_lower_string()
|
|
||||||
item_in = ItemCreate(title=title, description=description)
|
|
||||||
return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { chromium } from "@playwright/test"
|
||||||
|
const APP = "http://app.localhost"
|
||||||
|
const OUT = "/tmp/claude-1000/-home-lc3267-Documents-CodeWorkspace-fluksio/8b6d42d6-2889-4321-ac59-4e6692fcfe3c/scratchpad"
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||||
|
const page = await ctx.newPage()
|
||||||
|
page.on("pageerror", (e) => console.log(`[pageerror] ${e.message}`))
|
||||||
|
await page.goto(`${APP}/login`)
|
||||||
|
await page.getByTestId("email-input").fill(process.env.FIRST_SUPERUSER)
|
||||||
|
await page.getByTestId("password-input").fill(process.env.FIRST_SUPERUSER_PASSWORD)
|
||||||
|
await page.getByRole("button", { name: /log in/i }).click()
|
||||||
|
await page.waitForURL(`${APP}/`)
|
||||||
|
|
||||||
|
console.log("sidebar items entry:", await page.getByRole("link", { name: "Items" }).count())
|
||||||
|
|
||||||
|
await page.goto(`${APP}/flows/heating`)
|
||||||
|
await page.waitForSelector(".react-flow__node")
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
|
||||||
|
// A node that consumes: its Consumes list should suggest what others publish.
|
||||||
|
await page.locator(".react-flow__node").filter({ hasText: "Notify" }).first().click()
|
||||||
|
const panel = page.getByTestId("node-panel")
|
||||||
|
await panel.getByRole("button", { name: "Add" }).first().click()
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
console.log("focused after Add:", await page.evaluate(() => document.activeElement?.getAttribute("aria-label")))
|
||||||
|
const options = await page.getByRole("option").allInnerTexts()
|
||||||
|
console.log("suggestions offered:", options)
|
||||||
|
await page.screenshot({ path: `${OUT}/suggestions.png` })
|
||||||
|
await browser.close()
|
||||||
@@ -292,131 +292,6 @@ export const HTTPValidationErrorSchema = {
|
|||||||
title: 'HTTPValidationError'
|
title: 'HTTPValidationError'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ItemCreateSchema = {
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
minLength: 1,
|
|
||||||
title: 'Title'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Description'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['title'],
|
|
||||||
title: 'ItemCreate'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ItemPublicSchema = {
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
minLength: 1,
|
|
||||||
title: 'Title'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Description'
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
title: 'Id'
|
|
||||||
},
|
|
||||||
owner_id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
title: 'Owner Id'
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Created At'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['title', 'id', 'owner_id'],
|
|
||||||
title: 'ItemPublic'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ItemUpdateSchema = {
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255,
|
|
||||||
minLength: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Title'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 255
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'null'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Description'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
title: 'ItemUpdate'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const ItemsPublicSchema = {
|
|
||||||
properties: {
|
|
||||||
data: {
|
|
||||||
items: {
|
|
||||||
'$ref': '#/components/schemas/ItemPublic'
|
|
||||||
},
|
|
||||||
type: 'array',
|
|
||||||
title: 'Data'
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: 'integer',
|
|
||||||
title: 'Count'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'object',
|
|
||||||
required: ['data', 'count'],
|
|
||||||
title: 'ItemsPublic'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const MessageSchema = {
|
export const MessageSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CancelablePromise } from './core/CancelablePromise';
|
import type { CancelablePromise } from './core/CancelablePromise';
|
||||||
import { OpenAPI } from './core/OpenAPI';
|
import { OpenAPI } from './core/OpenAPI';
|
||||||
import { request as __request } from './core/request';
|
import { request as __request } from './core/request';
|
||||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||||
|
|
||||||
export class FlowsService {
|
export class FlowsService {
|
||||||
/**
|
/**
|
||||||
@@ -240,117 +240,6 @@ export class FlowsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ItemsService {
|
|
||||||
/**
|
|
||||||
* Read Items
|
|
||||||
* Retrieve items.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.skip
|
|
||||||
* @param data.limit
|
|
||||||
* @returns ItemsPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static readItems(data: ItemsReadItemsData = {}): CancelablePromise<ItemsReadItemsResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/items/',
|
|
||||||
query: {
|
|
||||||
skip: data.skip,
|
|
||||||
limit: data.limit
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Item
|
|
||||||
* Create new item.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns ItemPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static createItem(data: ItemsCreateItemData): CancelablePromise<ItemsCreateItemResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/v1/items/',
|
|
||||||
body: data.requestBody,
|
|
||||||
mediaType: 'application/json',
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read Item
|
|
||||||
* Get item by ID.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.id
|
|
||||||
* @returns ItemPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static readItem(data: ItemsReadItemData): CancelablePromise<ItemsReadItemResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/items/{id}',
|
|
||||||
path: {
|
|
||||||
id: data.id
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Item
|
|
||||||
* Update an item.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.id
|
|
||||||
* @param data.requestBody
|
|
||||||
* @returns ItemPublic Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static updateItem(data: ItemsUpdateItemData): CancelablePromise<ItemsUpdateItemResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'PUT',
|
|
||||||
url: '/api/v1/items/{id}',
|
|
||||||
path: {
|
|
||||||
id: data.id
|
|
||||||
},
|
|
||||||
body: data.requestBody,
|
|
||||||
mediaType: 'application/json',
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete Item
|
|
||||||
* Delete an item.
|
|
||||||
* @param data The data for the request.
|
|
||||||
* @param data.id
|
|
||||||
* @returns Message Successful Response
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
|
||||||
public static deleteItem(data: ItemsDeleteItemData): CancelablePromise<ItemsDeleteItemResponse> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/api/v1/items/{id}',
|
|
||||||
path: {
|
|
||||||
id: data.id
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
422: 'Validation Error'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LoginService {
|
export class LoginService {
|
||||||
/**
|
/**
|
||||||
* Login Access Token
|
* Login Access Token
|
||||||
|
|||||||
@@ -87,29 +87,6 @@ export type HTTPValidationError = {
|
|||||||
detail?: Array<ValidationError>;
|
detail?: Array<ValidationError>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ItemCreate = {
|
|
||||||
title: string;
|
|
||||||
description?: (string | null);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemPublic = {
|
|
||||||
title: string;
|
|
||||||
description?: (string | null);
|
|
||||||
id: string;
|
|
||||||
owner_id: string;
|
|
||||||
created_at?: (string | null);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemsPublic = {
|
|
||||||
data: Array<ItemPublic>;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemUpdate = {
|
|
||||||
title?: (string | null);
|
|
||||||
description?: (string | null);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
@@ -378,38 +355,6 @@ export type FlowsReadFlowStateData = {
|
|||||||
|
|
||||||
export type FlowsReadFlowStateResponse = (FlowStatePublic);
|
export type FlowsReadFlowStateResponse = (FlowStatePublic);
|
||||||
|
|
||||||
export type ItemsReadItemsData = {
|
|
||||||
limit?: number;
|
|
||||||
skip?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemsReadItemsResponse = (ItemsPublic);
|
|
||||||
|
|
||||||
export type ItemsCreateItemData = {
|
|
||||||
requestBody: ItemCreate;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemsCreateItemResponse = (ItemPublic);
|
|
||||||
|
|
||||||
export type ItemsReadItemData = {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemsReadItemResponse = (ItemPublic);
|
|
||||||
|
|
||||||
export type ItemsUpdateItemData = {
|
|
||||||
id: string;
|
|
||||||
requestBody: ItemUpdate;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemsUpdateItemResponse = (ItemPublic);
|
|
||||||
|
|
||||||
export type ItemsDeleteItemData = {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ItemsDeleteItemResponse = (Message);
|
|
||||||
|
|
||||||
export type LoginLoginAccessTokenData = {
|
export type LoginLoginAccessTokenData = {
|
||||||
formData: Body_login_login_access_token;
|
formData: Body_login_login_access_token;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import { useNavigate } from "@tanstack/react-router"
|
|||||||
import { Workflow } from "lucide-react"
|
import { Workflow } from "lucide-react"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
|
||||||
import { type FlowDef_Input, FlowsService, type NodeDef_Input } from "@/client"
|
import {
|
||||||
|
type FlowDef_Input,
|
||||||
|
FlowsService,
|
||||||
|
type MessageSpec,
|
||||||
|
type NodeDef_Input,
|
||||||
|
} from "@/client"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -54,9 +59,11 @@ const edgeTypes = { live: LiveEdge }
|
|||||||
|
|
||||||
type Rebind = {
|
type Rebind = {
|
||||||
nodeId: string
|
nodeId: string
|
||||||
|
nodeLabel: string
|
||||||
port: string
|
port: string
|
||||||
from: string
|
from: string
|
||||||
to: string
|
to: string
|
||||||
|
dtype: MessageSpec["dtype"]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step a new node off any node already sitting at that spot. */
|
/** Step a new node off any node already sitting at that spot. */
|
||||||
@@ -167,8 +174,31 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
|||||||
[canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels],
|
[canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Edges follow from the name bindings, so they are derived, never stored.
|
// A cheap fingerprint of the wiring: it changes when a name does, but not
|
||||||
|
// when a node merely moves.
|
||||||
const key = bindingsKey(definitions)
|
const key = bindingsKey(definitions)
|
||||||
|
|
||||||
|
// Offer the names already in play: everything published is worth reading,
|
||||||
|
// and an input nobody provides yet is worth publishing.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes names.
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const provided = new Set<string>()
|
||||||
|
const consumed = new Set<string>()
|
||||||
|
for (const node of definitions) {
|
||||||
|
for (const spec of node.provides ?? []) {
|
||||||
|
if (spec.name) provided.add(spec.name)
|
||||||
|
}
|
||||||
|
for (const spec of node.requires ?? []) {
|
||||||
|
if (spec.name) consumed.add(spec.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
consumes: [...provided].sort(),
|
||||||
|
provides: [...consumed].filter((name) => !provided.has(name)).sort(),
|
||||||
|
}
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
// Edges follow from the name bindings, so they are derived, never stored.
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
||||||
const edges = useMemo(
|
const edges = useMemo(
|
||||||
() => deriveEdges(definitions, flowName),
|
() => deriveEdges(definitions, flowName),
|
||||||
@@ -297,12 +327,16 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
|||||||
)
|
)
|
||||||
if (!outSpec?.name || !inSpec) return
|
if (!outSpec?.name || !inSpec) return
|
||||||
|
|
||||||
|
// Already reading something else: the user may want either message, so
|
||||||
|
// offer the extra port rather than assuming a replacement.
|
||||||
if (inSpec.name && inSpec.name !== outSpec.name) {
|
if (inSpec.name && inSpec.name !== outSpec.name) {
|
||||||
setRebind({
|
setRebind({
|
||||||
nodeId: consumer.id,
|
nodeId: consumer.id,
|
||||||
|
nodeLabel: consumer.title || consumer.id,
|
||||||
port: portOf(inSpec),
|
port: portOf(inSpec),
|
||||||
from: inSpec.name,
|
from: inSpec.name,
|
||||||
to: outSpec.name,
|
to: outSpec.name,
|
||||||
|
dtype: outSpec.dtype,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -312,6 +346,26 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
|||||||
[definitions, applyBinding],
|
[definitions, applyBinding],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Give the consumer a second input, bound to the producer's message. */
|
||||||
|
const addInputPort = useCallback(
|
||||||
|
(nodeId: string, message: string, dtype: MessageSpec["dtype"]) => {
|
||||||
|
commit(
|
||||||
|
definitions.map((node) =>
|
||||||
|
node.id === nodeId
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
requires: [
|
||||||
|
...(node.requires ?? []),
|
||||||
|
{ name: message, port: "", dtype },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[commit, definitions],
|
||||||
|
)
|
||||||
|
|
||||||
const unbind = useCallback(
|
const unbind = useCallback(
|
||||||
(message: string) => {
|
(message: string) => {
|
||||||
const qualified = qualify(flowName, message)
|
const qualified = qualify(flowName, message)
|
||||||
@@ -420,6 +474,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
|||||||
node={selected}
|
node={selected}
|
||||||
flow={flowName}
|
flow={flowName}
|
||||||
nodeTypes={nodeTypeInfo ?? []}
|
nodeTypes={nodeTypeInfo ?? []}
|
||||||
|
suggestions={suggestions}
|
||||||
onChange={updateNode}
|
onChange={updateNode}
|
||||||
onSaveSource={(code) => {
|
onSaveSource={(code) => {
|
||||||
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
|
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
|
||||||
@@ -453,24 +508,41 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Change what this input reads?</DialogTitle>
|
<DialogTitle>How should {rebind?.nodeLabel} read this?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
"{rebind?.port}" currently reads {rebind?.from}. Point it at{" "}
|
Its "{rebind?.port}" input already reads{" "}
|
||||||
{rebind?.to} instead?
|
<span className="font-mono">{rebind?.from}</span>. It can take{" "}
|
||||||
|
<span className="font-mono">{rebind?.to}</span> as well, or
|
||||||
|
instead.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter className="sm:justify-between">
|
||||||
<Button variant="outline" onClick={() => setRebind(null)}>
|
<Button variant="ghost" onClick={() => setRebind(null)}>
|
||||||
Keep {rebind?.from}
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (rebind) applyBinding(rebind.nodeId, rebind.port, rebind.to)
|
if (rebind) {
|
||||||
|
applyBinding(rebind.nodeId, rebind.port, rebind.to)
|
||||||
|
}
|
||||||
setRebind(null)
|
setRebind(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Read {rebind?.to}
|
Replace
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (rebind) {
|
||||||
|
addInputPort(rebind.nodeId, rebind.to, rebind.dtype)
|
||||||
|
}
|
||||||
|
setRebind(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add as another input
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
|||||||
|
|
||||||
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
|
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -42,19 +50,107 @@ const panelSlide = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message name, typed freely or picked from the names already in play.
|
||||||
|
*
|
||||||
|
* The suggestions are the point: a message only connects when both ends spell
|
||||||
|
* it the same way, so choosing beats typing.
|
||||||
|
*/
|
||||||
|
function MessageNameInput({
|
||||||
|
value,
|
||||||
|
suggestions,
|
||||||
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
suggestions: string[]
|
||||||
|
placeholder: string
|
||||||
|
autoFocus: boolean
|
||||||
|
onChange: (next: string) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const matches = suggestions.filter(
|
||||||
|
(name) =>
|
||||||
|
name !== value && name.toLowerCase().includes(value.toLowerCase()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label="Message name"
|
||||||
|
autoComplete="off"
|
||||||
|
// A port added by hand is meant to be named right away.
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="h-8 flex-1 font-mono text-sm"
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange(event.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverAnchor>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-[--radix-popover-trigger-width] p-0"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No matching message.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{matches.map((name) => (
|
||||||
|
<CommandItem
|
||||||
|
key={name}
|
||||||
|
value={name}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
// Blur fires before click, so commit on mousedown.
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onChange(name)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(name)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PortList({
|
function PortList({
|
||||||
title,
|
title,
|
||||||
specs,
|
specs,
|
||||||
flow,
|
flow,
|
||||||
emptyHint,
|
emptyHint,
|
||||||
|
suggestions,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
specs: MessageSpec[]
|
specs: MessageSpec[]
|
||||||
flow: string
|
flow: string
|
||||||
emptyHint: string
|
emptyHint: string
|
||||||
|
suggestions: string[]
|
||||||
onChange: (next: MessageSpec[]) => void
|
onChange: (next: MessageSpec[]) => void
|
||||||
}) {
|
}) {
|
||||||
|
// The port just added, so its name field can take focus.
|
||||||
|
const [freshIndex, setFreshIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const update = (index: number, patch: Partial<MessageSpec>) => {
|
const update = (index: number, patch: Partial<MessageSpec>) => {
|
||||||
const next = specs.map((spec, i) =>
|
const next = specs.map((spec, i) =>
|
||||||
i === index ? { ...spec, ...patch } : spec,
|
i === index ? { ...spec, ...patch } : spec,
|
||||||
@@ -70,7 +166,10 @@ function PortList({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs text-muted-foreground"
|
className="h-7 text-xs text-muted-foreground"
|
||||||
onClick={() => onChange([...specs, { name: "", dtype: "float" }])}
|
onClick={() => {
|
||||||
|
setFreshIndex(specs.length)
|
||||||
|
onChange([...specs, { name: "", dtype: "float" }])
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -82,14 +181,12 @@ function PortList({
|
|||||||
|
|
||||||
{specs.map((spec, index) => (
|
{specs.map((spec, index) => (
|
||||||
<div key={`port-${index}`} className="flex items-center gap-1.5">
|
<div key={`port-${index}`} className="flex items-center gap-1.5">
|
||||||
<Input
|
<MessageNameInput
|
||||||
value={spec.name ?? ""}
|
value={spec.name ?? ""}
|
||||||
|
suggestions={suggestions}
|
||||||
placeholder={`name in ${flow}`}
|
placeholder={`name in ${flow}`}
|
||||||
aria-label="Message name"
|
autoFocus={index === freshIndex}
|
||||||
className="h-8 flex-1 font-mono text-sm"
|
onChange={(name) => update(index, { name, port: "" })}
|
||||||
onChange={(event) =>
|
|
||||||
update(index, { name: event.target.value, port: "" })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={spec.dtype ?? "float"}
|
value={spec.dtype ?? "float"}
|
||||||
@@ -197,6 +294,7 @@ function PanelBody({
|
|||||||
node,
|
node,
|
||||||
flow,
|
flow,
|
||||||
nodeType,
|
nodeType,
|
||||||
|
suggestions,
|
||||||
onChange,
|
onChange,
|
||||||
onSaveSource,
|
onSaveSource,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -205,6 +303,7 @@ function PanelBody({
|
|||||||
node: NodeDef_Input
|
node: NodeDef_Input
|
||||||
flow: string
|
flow: string
|
||||||
nodeType: NodeTypeInfo | undefined
|
nodeType: NodeTypeInfo | undefined
|
||||||
|
suggestions: PortSuggestions
|
||||||
onChange: (next: NodeDef_Input) => void
|
onChange: (next: NodeDef_Input) => void
|
||||||
onSaveSource: (code: string) => void
|
onSaveSource: (code: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -269,6 +368,7 @@ function PanelBody({
|
|||||||
specs={node.requires ?? []}
|
specs={node.requires ?? []}
|
||||||
flow={flow}
|
flow={flow}
|
||||||
emptyHint="Nothing yet. Add a message this node reads."
|
emptyHint="Nothing yet. Add a message this node reads."
|
||||||
|
suggestions={suggestions.consumes}
|
||||||
onChange={(requires) => onChange({ ...node, requires })}
|
onChange={(requires) => onChange({ ...node, requires })}
|
||||||
/>
|
/>
|
||||||
<PortList
|
<PortList
|
||||||
@@ -276,6 +376,7 @@ function PanelBody({
|
|||||||
specs={node.provides ?? []}
|
specs={node.provides ?? []}
|
||||||
flow={flow}
|
flow={flow}
|
||||||
emptyHint="Nothing yet. Add a message this node publishes."
|
emptyHint="Nothing yet. Add a message this node publishes."
|
||||||
|
suggestions={suggestions.provides}
|
||||||
onChange={(provides) => onChange({ ...node, provides })}
|
onChange={(provides) => onChange({ ...node, provides })}
|
||||||
/>
|
/>
|
||||||
<ParamsForm
|
<ParamsForm
|
||||||
@@ -322,10 +423,14 @@ function PanelBody({
|
|||||||
* Node settings, floating over the canvas so the graph stays visible and live.
|
* Node settings, floating over the canvas so the graph stays visible and live.
|
||||||
* On a phone there is no room for that, so it becomes a full-screen sheet.
|
* On a phone there is no room for that, so it becomes a full-screen sheet.
|
||||||
*/
|
*/
|
||||||
|
/** Message names worth offering on each side of a node. */
|
||||||
|
export type PortSuggestions = { consumes: string[]; provides: string[] }
|
||||||
|
|
||||||
export function NodePanel({
|
export function NodePanel({
|
||||||
node,
|
node,
|
||||||
flow,
|
flow,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
|
suggestions,
|
||||||
onChange,
|
onChange,
|
||||||
onSaveSource,
|
onSaveSource,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -334,6 +439,7 @@ export function NodePanel({
|
|||||||
node: NodeDef_Input | null
|
node: NodeDef_Input | null
|
||||||
flow: string
|
flow: string
|
||||||
nodeTypes: NodeTypeInfo[]
|
nodeTypes: NodeTypeInfo[]
|
||||||
|
suggestions: PortSuggestions
|
||||||
onChange: (next: NodeDef_Input) => void
|
onChange: (next: NodeDef_Input) => void
|
||||||
onSaveSource: (code: string) => void
|
onSaveSource: (code: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -368,6 +474,7 @@ export function NodePanel({
|
|||||||
node={node}
|
node={node}
|
||||||
flow={flow}
|
flow={flow}
|
||||||
nodeType={nodeType}
|
nodeType={nodeType}
|
||||||
|
suggestions={suggestions}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onSaveSource={onSaveSource}
|
onSaveSource={onSaveSource}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -397,6 +504,7 @@ export function NodePanel({
|
|||||||
node={node}
|
node={node}
|
||||||
flow={flow}
|
flow={flow}
|
||||||
nodeType={nodeType}
|
nodeType={nodeType}
|
||||||
|
suggestions={suggestions}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onSaveSource={onSaveSource}
|
onSaveSource={onSaveSource}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Plus } from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { type ItemCreate, ItemsService } from "@/client"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { LoadingButton } from "@/components/ui/loading-button"
|
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
|
||||||
import { handleError } from "@/utils"
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
title: z.string().min(1, { message: "Title is required" }),
|
|
||||||
description: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>
|
|
||||||
|
|
||||||
const AddItem = () => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
mode: "onBlur",
|
|
||||||
criteriaMode: "all",
|
|
||||||
defaultValues: {
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (data: ItemCreate) =>
|
|
||||||
ItemsService.createItem({ requestBody: data }),
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccessToast("Item created successfully")
|
|
||||||
form.reset()
|
|
||||||
setIsOpen(false)
|
|
||||||
},
|
|
||||||
onError: handleError.bind(showErrorToast),
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => {
|
|
||||||
mutation.mutate(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="my-4">
|
|
||||||
<Plus className="mr-2" />
|
|
||||||
Add Item
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Item</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Fill in the details to add a new item.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="title"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Title <span className="text-destructive">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Title"
|
|
||||||
type="text"
|
|
||||||
{...field}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Description" type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={mutation.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<LoadingButton type="submit" loading={mutation.isPending}>
|
|
||||||
Save
|
|
||||||
</LoadingButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddItem
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Trash2 } from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
|
|
||||||
import { ItemsService } from "@/client"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
|
||||||
import { LoadingButton } from "@/components/ui/loading-button"
|
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
|
||||||
import { handleError } from "@/utils"
|
|
||||||
|
|
||||||
interface DeleteItemProps {
|
|
||||||
id: string
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
|
||||||
const { handleSubmit } = useForm()
|
|
||||||
|
|
||||||
const deleteItem = async (id: string) => {
|
|
||||||
await ItemsService.deleteItem({ id: id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: deleteItem,
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccessToast("The item was deleted successfully")
|
|
||||||
setIsOpen(false)
|
|
||||||
onSuccess()
|
|
||||||
},
|
|
||||||
onError: handleError.bind(showErrorToast),
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
mutation.mutate(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete Item
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Item</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This item will be permanently deleted. Are you sure? You will not
|
|
||||||
be able to undo this action.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={mutation.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<LoadingButton
|
|
||||||
variant="destructive"
|
|
||||||
type="submit"
|
|
||||||
loading={mutation.isPending}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</LoadingButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DeleteItem
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Pencil } from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { type ItemPublic, ItemsService } from "@/client"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { LoadingButton } from "@/components/ui/loading-button"
|
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
|
||||||
import { handleError } from "@/utils"
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
title: z.string().min(1, { message: "Title is required" }),
|
|
||||||
description: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>
|
|
||||||
|
|
||||||
interface EditItemProps {
|
|
||||||
item: ItemPublic
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
mode: "onBlur",
|
|
||||||
criteriaMode: "all",
|
|
||||||
defaultValues: {
|
|
||||||
title: item.title,
|
|
||||||
description: item.description ?? undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (data: FormData) =>
|
|
||||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
|
||||||
onSuccess: () => {
|
|
||||||
showSuccessToast("Item updated successfully")
|
|
||||||
setIsOpen(false)
|
|
||||||
onSuccess()
|
|
||||||
},
|
|
||||||
onError: handleError.bind(showErrorToast),
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => {
|
|
||||||
mutation.mutate(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
Edit Item
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit Item</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Update the item details below.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="title"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Title <span className="text-destructive">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Title" type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Description" type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline" disabled={mutation.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<LoadingButton type="submit" loading={mutation.isPending}>
|
|
||||||
Save
|
|
||||||
</LoadingButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EditItem
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { EllipsisVertical } from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
import type { ItemPublic } from "@/client"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import DeleteItem from "../Items/DeleteItem"
|
|
||||||
import EditItem from "../Items/EditItem"
|
|
||||||
|
|
||||||
interface ItemActionsMenuProps {
|
|
||||||
item: ItemPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<EllipsisVertical />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<EditItem item={item} onSuccess={() => setOpen(false)} />
|
|
||||||
<DeleteItem id={item.id} onSuccess={() => setOpen(false)} />
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { Check, Copy } from "lucide-react"
|
|
||||||
|
|
||||||
import type { ItemPublic } from "@/client"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ItemActionsMenu } from "./ItemActionsMenu"
|
|
||||||
|
|
||||||
function CopyId({ id }: { id: string }) {
|
|
||||||
const [copiedText, copy] = useCopyToClipboard()
|
|
||||||
const isCopied = copiedText === id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 group">
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{id}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => copy(id)}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<Check className="size-3 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="size-3" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">Copy ID</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const columns: ColumnDef<ItemPublic>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "id",
|
|
||||||
header: "ID",
|
|
||||||
cell: ({ row }) => <CopyId id={row.original.id} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "title",
|
|
||||||
header: "Title",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-medium">{row.original.title}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "description",
|
|
||||||
header: "Description",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const description = row.original.description
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"max-w-xs truncate block text-muted-foreground",
|
|
||||||
!description && "italic",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{description || "No description"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<ItemActionsMenu item={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
|
|
||||||
const PendingItems = () => (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>ID</TableHead>
|
|
||||||
<TableHead>Title</TableHead>
|
|
||||||
<TableHead>Description</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
|
||||||
<TableRow key={index}>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-64 font-mono" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-48" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Skeleton className="size-8 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default PendingItems
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Briefcase, Home, Users, Workflow } from "lucide-react"
|
import { Home, Users, Workflow } from "lucide-react"
|
||||||
|
|
||||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||||
import { Logo } from "@/components/Common/Logo"
|
import { Logo } from "@/components/Common/Logo"
|
||||||
@@ -15,7 +15,6 @@ import { User } from "./User"
|
|||||||
const baseItems: Item[] = [
|
const baseItems: Item[] = [
|
||||||
{ icon: Home, title: "Dashboard", path: "/" },
|
{ icon: Home, title: "Dashboard", path: "/" },
|
||||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||||
{ icon: Briefcase, title: "Items", path: "/items" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { Route as LayoutRouteImport } from './routes/_layout'
|
|||||||
import { Route as CanvasRouteImport } from './routes/_canvas'
|
import { Route as CanvasRouteImport } from './routes/_canvas'
|
||||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
||||||
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
||||||
@@ -60,11 +59,6 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
|
||||||
id: '/items',
|
|
||||||
path: '/items',
|
|
||||||
getParentRoute: () => LayoutRoute,
|
|
||||||
} as any)
|
|
||||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||||
id: '/admin',
|
id: '/admin',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@@ -88,7 +82,6 @@ export interface FileRoutesByFullPath {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/items': typeof LayoutItemsRoute
|
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
'/flows/': typeof CanvasFlowsIndexRoute
|
'/flows/': typeof CanvasFlowsIndexRoute
|
||||||
@@ -100,7 +93,6 @@ export interface FileRoutesByTo {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/items': typeof LayoutItemsRoute
|
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
'/flows': typeof CanvasFlowsIndexRoute
|
'/flows': typeof CanvasFlowsIndexRoute
|
||||||
@@ -114,7 +106,6 @@ export interface FileRoutesById {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/_layout/admin': typeof LayoutAdminRoute
|
'/_layout/admin': typeof LayoutAdminRoute
|
||||||
'/_layout/items': typeof LayoutItemsRoute
|
|
||||||
'/_layout/settings': typeof LayoutSettingsRoute
|
'/_layout/settings': typeof LayoutSettingsRoute
|
||||||
'/_layout/': typeof LayoutIndexRoute
|
'/_layout/': typeof LayoutIndexRoute
|
||||||
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
@@ -129,7 +120,6 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/items'
|
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
| '/flows/'
|
| '/flows/'
|
||||||
@@ -141,7 +131,6 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/items'
|
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
| '/flows'
|
| '/flows'
|
||||||
@@ -154,7 +143,6 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/_layout/admin'
|
| '/_layout/admin'
|
||||||
| '/_layout/items'
|
|
||||||
| '/_layout/settings'
|
| '/_layout/settings'
|
||||||
| '/_layout/'
|
| '/_layout/'
|
||||||
| '/_canvas/flows/$flowName'
|
| '/_canvas/flows/$flowName'
|
||||||
@@ -228,13 +216,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/items': {
|
|
||||||
id: '/_layout/items'
|
|
||||||
path: '/items'
|
|
||||||
fullPath: '/items'
|
|
||||||
preLoaderRoute: typeof LayoutItemsRouteImport
|
|
||||||
parentRoute: typeof LayoutRoute
|
|
||||||
}
|
|
||||||
'/_layout/admin': {
|
'/_layout/admin': {
|
||||||
id: '/_layout/admin'
|
id: '/_layout/admin'
|
||||||
path: '/admin'
|
path: '/admin'
|
||||||
@@ -274,14 +255,12 @@ const CanvasRouteWithChildren =
|
|||||||
|
|
||||||
interface LayoutRouteChildren {
|
interface LayoutRouteChildren {
|
||||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||||
LayoutItemsRoute: typeof LayoutItemsRoute
|
|
||||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||||
LayoutAdminRoute: LayoutAdminRoute,
|
LayoutAdminRoute: LayoutAdminRoute,
|
||||||
LayoutItemsRoute: LayoutItemsRoute,
|
|
||||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||||
LayoutIndexRoute: LayoutIndexRoute,
|
LayoutIndexRoute: LayoutIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useSuspenseQuery } from "@tanstack/react-query"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Search } from "lucide-react"
|
|
||||||
import { Suspense } from "react"
|
|
||||||
|
|
||||||
import { ItemsService } from "@/client"
|
|
||||||
import { DataTable } from "@/components/Common/DataTable"
|
|
||||||
import AddItem from "@/components/Items/AddItem"
|
|
||||||
import { columns } from "@/components/Items/columns"
|
|
||||||
import PendingItems from "@/components/Pending/PendingItems"
|
|
||||||
|
|
||||||
function getItemsQueryOptions() {
|
|
||||||
return {
|
|
||||||
queryFn: () => ItemsService.readItems({ skip: 0, limit: 100 }),
|
|
||||||
queryKey: ["items"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_layout/items")({
|
|
||||||
component: Items,
|
|
||||||
head: () => ({
|
|
||||||
meta: [
|
|
||||||
{
|
|
||||||
title: "Items - Fluksio",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
function ItemsTableContent() {
|
|
||||||
const { data: items } = useSuspenseQuery(getItemsQueryOptions())
|
|
||||||
|
|
||||||
if (items.data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center text-center py-12">
|
|
||||||
<div className="rounded-full bg-muted p-4 mb-4">
|
|
||||||
<Search className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold">You don't have any items yet</h3>
|
|
||||||
<p className="text-muted-foreground">Add a new item to get started</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DataTable columns={columns} data={items.data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemsTable() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<PendingItems />}>
|
|
||||||
<ItemsTableContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Items() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Items</h1>
|
|
||||||
<p className="text-muted-foreground">Create and manage your items</p>
|
|
||||||
</div>
|
|
||||||
<AddItem />
|
|
||||||
</div>
|
|
||||||
<ItemsTable />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import { createUser } from "./utils/privateApi"
|
|
||||||
import {
|
|
||||||
randomEmail,
|
|
||||||
randomItemDescription,
|
|
||||||
randomItemTitle,
|
|
||||||
randomPassword,
|
|
||||||
} from "./utils/random"
|
|
||||||
import { logInUser } from "./utils/user"
|
|
||||||
|
|
||||||
test("Items page is accessible and shows correct title", async ({ page }) => {
|
|
||||||
await page.goto("/items")
|
|
||||||
await expect(page.getByRole("heading", { name: "Items" })).toBeVisible()
|
|
||||||
await expect(page.getByText("Create and manage your items")).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Add Item button is visible", async ({ page }) => {
|
|
||||||
await page.goto("/items")
|
|
||||||
await expect(page.getByRole("button", { name: "Add Item" })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe("Items management", () => {
|
|
||||||
test.use({ storageState: { cookies: [], origins: [] } })
|
|
||||||
let email: string
|
|
||||||
const password = randomPassword()
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
email = randomEmail()
|
|
||||||
await createUser({ email, password })
|
|
||||||
})
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await logInUser(page, email, password)
|
|
||||||
await page.goto("/items")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Create a new item successfully", async ({ page }) => {
|
|
||||||
const title = randomItemTitle()
|
|
||||||
const description = randomItemDescription()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add Item" }).click()
|
|
||||||
await page.getByLabel("Title").fill(title)
|
|
||||||
await page.getByLabel("Description").fill(description)
|
|
||||||
await page.getByRole("button", { name: "Save" }).click()
|
|
||||||
|
|
||||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
|
||||||
await expect(page.getByText(title)).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Create item with only required fields", async ({ page }) => {
|
|
||||||
const title = randomItemTitle()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add Item" }).click()
|
|
||||||
await page.getByLabel("Title").fill(title)
|
|
||||||
await page.getByRole("button", { name: "Save" }).click()
|
|
||||||
|
|
||||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
|
||||||
await expect(page.getByText(title)).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Cancel item creation", async ({ page }) => {
|
|
||||||
await page.getByRole("button", { name: "Add Item" }).click()
|
|
||||||
await page.getByLabel("Title").fill("Test Item")
|
|
||||||
await page.getByRole("button", { name: "Cancel" }).click()
|
|
||||||
|
|
||||||
await expect(page.getByRole("dialog")).not.toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Title is required", async ({ page }) => {
|
|
||||||
await page.getByRole("button", { name: "Add Item" }).click()
|
|
||||||
await page.getByLabel("Title").fill("")
|
|
||||||
await page.getByLabel("Title").blur()
|
|
||||||
|
|
||||||
await expect(page.getByText("Title is required")).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe("Edit and Delete", () => {
|
|
||||||
let itemTitle: string
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
itemTitle = randomItemTitle()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add Item" }).click()
|
|
||||||
await page.getByLabel("Title").fill(itemTitle)
|
|
||||||
await page.getByRole("button", { name: "Save" }).click()
|
|
||||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
|
||||||
await expect(page.getByRole("dialog")).not.toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Edit an item successfully", async ({ page }) => {
|
|
||||||
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
|
|
||||||
await itemRow.getByRole("button").last().click()
|
|
||||||
await page.getByRole("menuitem", { name: "Edit Item" }).click()
|
|
||||||
|
|
||||||
const updatedTitle = randomItemTitle()
|
|
||||||
await page.getByLabel("Title").fill(updatedTitle)
|
|
||||||
await page.getByRole("button", { name: "Save" }).click()
|
|
||||||
|
|
||||||
await expect(page.getByText("Item updated successfully")).toBeVisible()
|
|
||||||
await expect(page.getByText(updatedTitle)).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Delete an item successfully", async ({ page }) => {
|
|
||||||
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
|
|
||||||
await itemRow.getByRole("button").last().click()
|
|
||||||
await page.getByRole("menuitem", { name: "Delete Item" }).click()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Delete" }).click()
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText("The item was deleted successfully"),
|
|
||||||
).toBeVisible()
|
|
||||||
await expect(page.getByText(itemTitle)).not.toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe("Items empty state", () => {
|
|
||||||
test.use({ storageState: { cookies: [], origins: [] } })
|
|
||||||
|
|
||||||
test("Shows empty state message when no items exist", async ({ page }) => {
|
|
||||||
const email = randomEmail()
|
|
||||||
const password = randomPassword()
|
|
||||||
await createUser({ email, password })
|
|
||||||
await logInUser(page, email, password)
|
|
||||||
|
|
||||||
await page.goto("/items")
|
|
||||||
|
|
||||||
await expect(page.getByText("You don't have any items yet")).toBeVisible()
|
|
||||||
await expect(page.getByText("Add a new item to get started")).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user