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:
Melvin Strobl
2026-08-15 18:43:17 +02:00
co-authored by Claude Fable 5
parent 8c82549cf6
commit 01af7787c1
27 changed files with 365 additions and 1433 deletions
@@ -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 -2
View File
@@ -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)
-112
View File
@@ -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")
+1 -4
View File
@@ -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")
+1 -10
View File
@@ -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
+4 -4
View File
@@ -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)
+1 -43
View File
@@ -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
-164
View File
@@ -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"
+1 -3
View File
@@ -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()
+82
View File
@@ -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)
-16
View File
@@ -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)
+29
View File
@@ -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()
-125
View File
@@ -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: {
+1 -112
View File
@@ -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<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 {
/**
* Login Access Token
-55
View File
@@ -87,29 +87,6 @@ export type HTTPValidationError = {
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 = {
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;
};
+88 -16
View File
@@ -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<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.
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 }) {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Change what this input reads?</DialogTitle>
<DialogTitle>How should {rebind?.nodeLabel} read this?</DialogTitle>
<DialogDescription>
"{rebind?.port}" currently reads {rebind?.from}. Point it at{" "}
{rebind?.to} instead?
Its "{rebind?.port}" input already reads{" "}
<span className="font-mono">{rebind?.from}</span>. It can take{" "}
<span className="font-mono">{rebind?.to}</span> as well, or
instead.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRebind(null)}>
Keep {rebind?.from}
</Button>
<Button
onClick={() => {
if (rebind) applyBinding(rebind.nodeId, rebind.port, rebind.to)
setRebind(null)
}}
>
Read {rebind?.to}
<DialogFooter className="sm:justify-between">
<Button variant="ghost" onClick={() => setRebind(null)}>
Cancel
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
if (rebind) {
applyBinding(rebind.nodeId, rebind.port, rebind.to)
}
setRebind(null)
}}
>
Replace
</Button>
<Button
onClick={() => {
if (rebind) {
addInputPort(rebind.nodeId, rebind.to, rebind.dtype)
}
setRebind(null)
}}
>
Add as another input
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
+115 -7
View File
@@ -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 (
<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({
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<number | null>(null)
const update = (index: number, patch: Partial<MessageSpec>) => {
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
</Button>
@@ -82,14 +181,12 @@ function PortList({
{specs.map((spec, index) => (
<div key={`port-${index}`} className="flex items-center gap-1.5">
<Input
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`name in ${flow}`}
aria-label="Message name"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) =>
update(index, { name: event.target.value, port: "" })
}
autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })}
/>
<Select
value={spec.dtype ?? "float"}
@@ -197,6 +294,7 @@ function PanelBody({
node,
flow,
nodeType,
suggestions,
onChange,
onSaveSource,
onClose,
@@ -205,6 +303,7 @@ function PanelBody({
node: NodeDef_Input
flow: string
nodeType: NodeTypeInfo | undefined
suggestions: PortSuggestions
onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void
onClose: () => void
@@ -269,6 +368,7 @@ function PanelBody({
specs={node.requires ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => onChange({ ...node, requires })}
/>
<PortList
@@ -276,6 +376,7 @@ function PanelBody({
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })}
/>
<ParamsForm
@@ -322,10 +423,14 @@ function PanelBody({
* 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.
*/
/** Message names worth offering on each side of a node. */
export type PortSuggestions = { consumes: string[]; provides: string[] }
export function NodePanel({
node,
flow,
nodeTypes,
suggestions,
onChange,
onSaveSource,
onClose,
@@ -334,6 +439,7 @@ export function NodePanel({
node: NodeDef_Input | null
flow: string
nodeTypes: NodeTypeInfo[]
suggestions: PortSuggestions
onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void
onClose: () => void
@@ -368,6 +474,7 @@ export function NodePanel({
node={node}
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}
@@ -397,6 +504,7 @@ export function NodePanel({
node={node}
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}
-144
View File
@@ -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
-145
View File
@@ -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>
)
}
-73
View File
@@ -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 { 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() {
-21
View File
@@ -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,
}
-69
View File
@@ -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>
)
}
-132
View File
@@ -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()
})
})