From 01af7787c168135a80b35310178b7295aa6a4dde Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Sat, 15 Aug 2026 18:43:17 +0200 Subject: [PATCH] Fix the MQTT node, suggest message names, and drop Items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i --- .../versions/b7c41d2f8a30_drop_item_table.py | 40 +++++ backend/app/api/main.py | 3 +- backend/app/api/routes/items.py | 112 ------------ backend/app/api/routes/users.py | 5 +- backend/app/crud.py | 11 +- backend/app/flow/nodes.py | 8 +- backend/app/models.py | 44 +---- backend/tests/api/routes/test_items.py | 164 ------------------ backend/tests/conftest.py | 4 +- backend/tests/flow/test_node_types.py | 82 +++++++++ backend/tests/utils/item.py | 16 -- frontend/scripts/_check.mjs | 29 ++++ frontend/src/client/schemas.gen.ts | 125 ------------- frontend/src/client/sdk.gen.ts | 113 +----------- frontend/src/client/types.gen.ts | 55 ------ frontend/src/components/Flow/FlowEditor.tsx | 104 +++++++++-- frontend/src/components/Flow/NodePanel.tsx | 122 ++++++++++++- frontend/src/components/Items/AddItem.tsx | 144 --------------- frontend/src/components/Items/DeleteItem.tsx | 94 ---------- frontend/src/components/Items/EditItem.tsx | 145 ---------------- .../src/components/Items/ItemActionsMenu.tsx | 34 ---- frontend/src/components/Items/columns.tsx | 73 -------- .../src/components/Pending/PendingItems.tsx | 46 ----- .../src/components/Sidebar/AppSidebar.tsx | 3 +- frontend/src/routeTree.gen.ts | 21 --- frontend/src/routes/_layout/items.tsx | 69 -------- frontend/tests/items.spec.ts | 132 -------------- 27 files changed, 365 insertions(+), 1433 deletions(-) create mode 100644 backend/app/alembic/versions/b7c41d2f8a30_drop_item_table.py delete mode 100644 backend/app/api/routes/items.py delete mode 100644 backend/tests/api/routes/test_items.py create mode 100644 backend/tests/flow/test_node_types.py delete mode 100644 backend/tests/utils/item.py create mode 100644 frontend/scripts/_check.mjs delete mode 100644 frontend/src/components/Items/AddItem.tsx delete mode 100644 frontend/src/components/Items/DeleteItem.tsx delete mode 100644 frontend/src/components/Items/EditItem.tsx delete mode 100644 frontend/src/components/Items/ItemActionsMenu.tsx delete mode 100644 frontend/src/components/Items/columns.tsx delete mode 100644 frontend/src/components/Pending/PendingItems.tsx delete mode 100644 frontend/src/routes/_layout/items.tsx delete mode 100644 frontend/tests/items.spec.ts diff --git a/backend/app/alembic/versions/b7c41d2f8a30_drop_item_table.py b/backend/app/alembic/versions/b7c41d2f8a30_drop_item_table.py new file mode 100644 index 0000000..372aeed --- /dev/null +++ b/backend/app/alembic/versions/b7c41d2f8a30_drop_item_table.py @@ -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"), + ) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 8c7a3a2..46299fb 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,13 +1,12 @@ 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 api_router = APIRouter() api_router.include_router(login.router) api_router.include_router(users.router) api_router.include_router(utils.router) -api_router.include_router(items.router) api_router.include_router(flows.router) api_router.include_router(flows.ws_router) api_router.include_router(secrets.router) diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py deleted file mode 100644 index f1929e5..0000000 --- a/backend/app/api/routes/items.py +++ /dev/null @@ -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") diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 35f64b6..de826f4 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -2,7 +2,7 @@ import uuid from typing import Any 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.api.deps import ( @@ -13,7 +13,6 @@ from app.api.deps import ( from app.core.config import settings from app.core.security import get_password_hash, verify_password from app.models import ( - Item, Message, UpdatePassword, User, @@ -224,8 +223,6 @@ def delete_user( raise HTTPException( 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.commit() return Message(message="User deleted successfully") diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6..434dff2 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,10 +1,9 @@ -import uuid from typing import Any from sqlmodel import Session, select 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: @@ -58,11 +57,3 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None: session.commit() session.refresh(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 diff --git a/backend/app/flow/nodes.py b/backend/app/flow/nodes.py index 6371d98..70a40f7 100644 --- a/backend/app/flow/nodes.py +++ b/backend/app/flow/nodes.py @@ -776,7 +776,7 @@ class MqttNode(Node): "qos", "retain", "keepalive", - "_topic_to_messages", + "_topic_to_ports", "_subscription_task", "_mqtt_client", "_stop_event", @@ -1001,7 +1001,7 @@ class MqttNode(Node): logger.info( "Started MQTT subscription for node '%s' to topics %s", self.name, - list(self._topic_to_messages.keys()), + list(self._topic_to_ports.keys()), ) async def stop_subscription(self) -> None: @@ -1034,7 +1034,7 @@ class MqttNode(Node): Background loop that listens for MQTT messages and triggers the pipeline. 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. """ import json @@ -1052,7 +1052,7 @@ class MqttNode(Node): keepalive=self.keepalive, ) as client: # 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) logger.info("[%s] Subscribed to %s", self.name, topic) diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0..30c5a37 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from pydantic import EmailStr from sqlalchemy import DateTime -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import Field, SQLModel def get_datetime_utc() -> datetime: @@ -53,7 +53,6 @@ class User(UserBase, table=True): default_factory=get_datetime_utc, 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 @@ -67,47 +66,6 @@ class UsersPublic(SQLModel): 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 class Message(SQLModel): message: str diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py deleted file mode 100644 index 3e82cd0..0000000 --- a/backend/tests/api/routes/test_items.py +++ /dev/null @@ -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" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 52e5f66..bc9f173 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,7 +7,7 @@ from sqlmodel import Session, delete from app.core.config import settings from app.core.db import engine, init_db 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.utils import get_superuser_token_headers @@ -26,8 +26,6 @@ def db() -> Generator[Session, None, None]: with Session(engine) as session: init_db(session) yield session - statement = delete(Item) - session.execute(statement) statement = delete(User) session.execute(statement) session.commit() diff --git a/backend/tests/flow/test_node_types.py b/backend/tests/flow/test_node_types.py new file mode 100644 index 0000000..df90e66 --- /dev/null +++ b/backend/tests/flow/test_node_types.py @@ -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) diff --git a/backend/tests/utils/item.py b/backend/tests/utils/item.py deleted file mode 100644 index ee51b35..0000000 --- a/backend/tests/utils/item.py +++ /dev/null @@ -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) diff --git a/frontend/scripts/_check.mjs b/frontend/scripts/_check.mjs new file mode 100644 index 0000000..d989b2b --- /dev/null +++ b/frontend/scripts/_check.mjs @@ -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() diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index b796334..c8fc769 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -292,131 +292,6 @@ export const HTTPValidationErrorSchema = { title: 'HTTPValidationError' } 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 = { properties: { message: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 3d5e56c..7a171c6 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; 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 { /** @@ -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 { - 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 { - 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 { - 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 { - 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 { - return __request(OpenAPI, { - method: 'DELETE', - url: '/api/v1/items/{id}', - path: { - id: data.id - }, - errors: { - 422: 'Validation Error' - } - }); - } -} - export class LoginService { /** * Login Access Token diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 95604f2..bf67110 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -87,29 +87,6 @@ export type HTTPValidationError = { detail?: Array; }; -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; - count: number; -}; - -export type ItemUpdate = { - title?: (string | null); - description?: (string | null); -}; - export type Message = { message: string; }; @@ -378,38 +355,6 @@ export type FlowsReadFlowStateData = { 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 = { formData: Body_login_login_access_token; }; diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index bf3746f..1652d04 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -20,7 +20,12 @@ import { useNavigate } from "@tanstack/react-router" import { Workflow } from "lucide-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 { Dialog, @@ -54,9 +59,11 @@ const edgeTypes = { live: LiveEdge } type Rebind = { nodeId: string + nodeLabel: string port: string from: string to: string + dtype: MessageSpec["dtype"] } /** 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], ) - // 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) + + // 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() + const consumed = new Set() + 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. const edges = useMemo( () => deriveEdges(definitions, flowName), @@ -297,12 +327,16 @@ function FlowEditorInner({ flowName }: { flowName: string }) { ) 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) { setRebind({ nodeId: consumer.id, + nodeLabel: consumer.title || consumer.id, port: portOf(inSpec), from: inSpec.name, to: outSpec.name, + dtype: outSpec.dtype, }) return } @@ -312,6 +346,26 @@ function FlowEditorInner({ flowName }: { flowName: string }) { [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( (message: string) => { const qualified = qualify(flowName, message) @@ -420,6 +474,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) { node={selected} flow={flowName} nodeTypes={nodeTypeInfo ?? []} + suggestions={suggestions} onChange={updateNode} onSaveSource={(code) => { if (selected) sourceMutation.mutate({ nodeId: selected.id, code }) @@ -453,24 +508,41 @@ function FlowEditorInner({ flowName }: { flowName: string }) { > - Change what this input reads? + How should {rebind?.nodeLabel} read this? - "{rebind?.port}" currently reads {rebind?.from}. Point it at{" "} - {rebind?.to} instead? + Its "{rebind?.port}" input already reads{" "} + {rebind?.from}. It can take{" "} + {rebind?.to} as well, or + instead. - - - +
+ + +
diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index d19cdea..b4364b3 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -5,8 +5,16 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react" import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client" import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover" import { Select, 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 ( + 0} onOpenChange={setOpen}> + + setOpen(true)} + onBlur={() => setOpen(false)} + onChange={(event) => { + onChange(event.target.value) + setOpen(true) + }} + onKeyDown={(event) => { + if (event.key === "Escape") setOpen(false) + }} + /> + + event.preventDefault()} + > + + + No matching message. + + {matches.map((name) => ( + { + event.preventDefault() + onChange(name) + setOpen(false) + }} + onSelect={() => { + onChange(name) + setOpen(false) + }} + > + {name} + + ))} + + + + + + ) +} + function PortList({ title, specs, flow, emptyHint, + suggestions, onChange, }: { title: string specs: MessageSpec[] flow: string emptyHint: string + suggestions: string[] onChange: (next: MessageSpec[]) => void }) { + // The port just added, so its name field can take focus. + const [freshIndex, setFreshIndex] = useState(null) + const update = (index: number, patch: Partial) => { const next = specs.map((spec, i) => i === index ? { ...spec, ...patch } : spec, @@ -70,7 +166,10 @@ function PortList({ variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" - onClick={() => onChange([...specs, { name: "", dtype: "float" }])} + onClick={() => { + setFreshIndex(specs.length) + onChange([...specs, { name: "", dtype: "float" }]) + }} > Add @@ -82,14 +181,12 @@ function PortList({ {specs.map((spec, index) => (
- - update(index, { name: event.target.value, port: "" }) - } + autoFocus={index === freshIndex} + onChange={(name) => update(index, { name, port: "" })} /> - - - - )} - /> - - ( - - Description - - - - - - )} - /> -
- - - - - - - Save - - - - - - - ) -} - -export default AddItem diff --git a/frontend/src/components/Items/DeleteItem.tsx b/frontend/src/components/Items/DeleteItem.tsx deleted file mode 100644 index 9e61c34..0000000 --- a/frontend/src/components/Items/DeleteItem.tsx +++ /dev/null @@ -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 ( - - e.preventDefault()} - onClick={() => setIsOpen(true)} - > - - Delete Item - - -
- - Delete Item - - This item will be permanently deleted. Are you sure? You will not - be able to undo this action. - - - - - - - - - Delete - - -
-
-
- ) -} - -export default DeleteItem diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx deleted file mode 100644 index 3d57f55..0000000 --- a/frontend/src/components/Items/EditItem.tsx +++ /dev/null @@ -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 - -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({ - 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 ( - - e.preventDefault()} - onClick={() => setIsOpen(true)} - > - - Edit Item - - -
- - - Edit Item - - Update the item details below. - - -
- ( - - - Title * - - - - - - - )} - /> - - ( - - Description - - - - - - )} - /> -
- - - - - - - Save - - -
- -
-
- ) -} - -export default EditItem diff --git a/frontend/src/components/Items/ItemActionsMenu.tsx b/frontend/src/components/Items/ItemActionsMenu.tsx deleted file mode 100644 index 1efe7bf..0000000 --- a/frontend/src/components/Items/ItemActionsMenu.tsx +++ /dev/null @@ -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 ( - - - - - - setOpen(false)} /> - setOpen(false)} /> - - - ) -} diff --git a/frontend/src/components/Items/columns.tsx b/frontend/src/components/Items/columns.tsx deleted file mode 100644 index b41be2a..0000000 --- a/frontend/src/components/Items/columns.tsx +++ /dev/null @@ -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 ( -
- {id} - -
- ) -} - -export const columns: ColumnDef[] = [ - { - accessorKey: "id", - header: "ID", - cell: ({ row }) => , - }, - { - accessorKey: "title", - header: "Title", - cell: ({ row }) => ( - {row.original.title} - ), - }, - { - accessorKey: "description", - header: "Description", - cell: ({ row }) => { - const description = row.original.description - return ( - - {description || "No description"} - - ) - }, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => ( -
- -
- ), - }, -] diff --git a/frontend/src/components/Pending/PendingItems.tsx b/frontend/src/components/Pending/PendingItems.tsx deleted file mode 100644 index 9658335..0000000 --- a/frontend/src/components/Pending/PendingItems.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" - -const PendingItems = () => ( - - - - ID - Title - Description - - Actions - - - - - {Array.from({ length: 5 }).map((_, index) => ( - - - - - - - - - - - -
- -
-
-
- ))} -
-
-) - -export default PendingItems diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index ae5a20e..5298490 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -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 { Logo } from "@/components/Common/Logo" @@ -15,7 +15,6 @@ import { User } from "./User" const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/" }, { icon: Workflow, title: "Flows", path: "/flows" }, - { icon: Briefcase, title: "Items", path: "/items" }, ] export function AppSidebar() { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index e832f73..fedf4de 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -17,7 +17,6 @@ import { Route as LayoutRouteImport } from './routes/_layout' import { Route as CanvasRouteImport } from './routes/_canvas' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' 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 CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index' import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName' @@ -60,11 +59,6 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ path: '/settings', getParentRoute: () => LayoutRoute, } as any) -const LayoutItemsRoute = LayoutItemsRouteImport.update({ - id: '/items', - path: '/items', - getParentRoute: () => LayoutRoute, -} as any) const LayoutAdminRoute = LayoutAdminRouteImport.update({ id: '/admin', path: '/admin', @@ -88,7 +82,6 @@ export interface FileRoutesByFullPath { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute - '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows/': typeof CanvasFlowsIndexRoute @@ -100,7 +93,6 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute - '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows': typeof CanvasFlowsIndexRoute @@ -114,7 +106,6 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/_layout/admin': typeof LayoutAdminRoute - '/_layout/items': typeof LayoutItemsRoute '/_layout/settings': typeof LayoutSettingsRoute '/_layout/': typeof LayoutIndexRoute '/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute @@ -129,7 +120,6 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' - | '/items' | '/settings' | '/flows/$flowName' | '/flows/' @@ -141,7 +131,6 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' - | '/items' | '/settings' | '/flows/$flowName' | '/flows' @@ -154,7 +143,6 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/_layout/admin' - | '/_layout/items' | '/_layout/settings' | '/_layout/' | '/_canvas/flows/$flowName' @@ -228,13 +216,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSettingsRouteImport parentRoute: typeof LayoutRoute } - '/_layout/items': { - id: '/_layout/items' - path: '/items' - fullPath: '/items' - preLoaderRoute: typeof LayoutItemsRouteImport - parentRoute: typeof LayoutRoute - } '/_layout/admin': { id: '/_layout/admin' path: '/admin' @@ -274,14 +255,12 @@ const CanvasRouteWithChildren = interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute - LayoutItemsRoute: typeof LayoutItemsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute } const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, - LayoutItemsRoute: LayoutItemsRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, } diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx deleted file mode 100644 index 70bef92..0000000 --- a/frontend/src/routes/_layout/items.tsx +++ /dev/null @@ -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 ( -
-
- -
-

You don't have any items yet

-

Add a new item to get started

-
- ) - } - - return -} - -function ItemsTable() { - return ( - }> - - - ) -} - -function Items() { - return ( -
-
-
-

Items

-

Create and manage your items

-
- -
- -
- ) -} diff --git a/frontend/tests/items.spec.ts b/frontend/tests/items.spec.ts deleted file mode 100644 index 5a43731..0000000 --- a/frontend/tests/items.spec.ts +++ /dev/null @@ -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() - }) -})