From 0446ce515e42cd2b039a624c8cece27953de14ba Mon Sep 17 00:00:00 2001 From: akira69 Date: Sat, 28 Feb 2026 13:18:27 -0600 Subject: [PATCH 1/3] feat(settings): add complex field framework --- client/public/locales/en/common.json | 23 ++++ .../pages/settings/complexFieldsSettings.tsx | 128 ++++++++++++++++++ client/src/pages/settings/index.tsx | 24 ++++ client/src/utils/queryFields.ts | 89 ++++++++++++ spoolman/api/v1/field.py | 49 +++++++ spoolman/complex_fields.py | 121 +++++++++++++++++ spoolman/settings.py | 3 + 7 files changed, 437 insertions(+) create mode 100644 client/src/pages/settings/complexFieldsSettings.tsx create mode 100644 spoolman/complex_fields.py diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..1cd73f3da 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -354,6 +354,29 @@ "key_not_changed": "Please change the key to something else.", "delete_confirm": "Delete field {{name}}?", "delete_confirm_description": "This will delete the field and all associated data for all entities." + }, + "complex_fields": { + "tab": "Complex Fields", + "description": "

Complex fields add optional, pre-defined behaviors beyond standard extra fields.

Enabling one can add specialized display, actions, calculated values, or list columns for the selected entity. Disabled features remain hidden so the standard UI stays simpler.

Each entry below states exactly what enabling it adds. This framework can stay empty until a specific advanced feature is installed.

", + "empty": "No complex fields are currently registered for this entity.", + "columns": { + "name": "Name", + "description": "Feature", + "enable_description": "What Enabling Adds", + "surfaces": "Surfaces", + "enabled": "Enabled" + }, + "surfaces": { + "show": "Show", + "edit": "Edit", + "list": "List", + "action": "Action", + "derived": "Derived" + }, + "messages": { + "enabled": "Enabled {{name}}.", + "disabled": "Disabled {{name}}." + } } }, "documentTitle": { diff --git a/client/src/pages/settings/complexFieldsSettings.tsx b/client/src/pages/settings/complexFieldsSettings.tsx new file mode 100644 index 000000000..804c6d1f1 --- /dev/null +++ b/client/src/pages/settings/complexFieldsSettings.tsx @@ -0,0 +1,128 @@ +import { useTranslate } from "@refinedev/core"; +import { Empty, Form, Space, Switch, Table, Tag, Typography, message } from "antd"; +import { ColumnType } from "antd/es/table"; +import { useState } from "react"; +import { Trans } from "react-i18next"; +import { useParams } from "react-router"; +import { + ComplexFieldState, + ComplexFieldSurface, + EntityType, + useGetComplexFields, + useSetComplexFieldEnabled, +} from "../../utils/queryFields"; + +export function ComplexFieldsSettings() { + const { entityType } = useParams<{ entityType: EntityType }>(); + const t = useTranslate(); + const [messageApi, contextHolder] = message.useMessage(); + const [pendingKey, setPendingKey] = useState(null); + const complexFields = useGetComplexFields(entityType as EntityType); + const setComplexFieldEnabled = useSetComplexFieldEnabled(entityType as EntityType); + + const niceName = t(`${entityType}.${entityType}`); + + const onToggle = async (record: ComplexFieldState, enabled: boolean) => { + try { + setPendingKey(record.key); + await setComplexFieldEnabled.mutateAsync({ + key: record.key, + enabled, + }); + messageApi.success( + t(enabled ? "settings.complex_fields.messages.enabled" : "settings.complex_fields.messages.disabled", { + name: record.name, + }), + ); + } catch (errInfo) { + if (errInfo instanceof Error) { + messageApi.error(errInfo.message); + } + } finally { + setPendingKey(null); + } + }; + + const columns: ColumnType[] = [ + { + title: t("settings.complex_fields.columns.name"), + dataIndex: "name", + key: "name", + width: "18%", + }, + { + title: t("settings.complex_fields.columns.description"), + dataIndex: "description", + key: "description", + width: "24%", + render: (value: string) => ( + {value} + ), + }, + { + title: t("settings.complex_fields.columns.enable_description"), + dataIndex: "enable_description", + key: "enable_description", + width: "32%", + render: (value: string) => ( + {value} + ), + }, + { + title: t("settings.complex_fields.columns.surfaces"), + dataIndex: "surfaces", + key: "surfaces", + width: "16%", + render: (surfaces: ComplexFieldSurface[]) => ( + + {surfaces.map((surface) => ( + {t(`settings.complex_fields.surfaces.${surface}`)} + ))} + + ), + }, + { + title: t("settings.complex_fields.columns.enabled"), + dataIndex: "enabled", + key: "enabled", + align: "center", + width: "10%", + render: (enabled: boolean, record) => ( + onToggle(record, checked)} + /> + ), + }, + ]; + + const rows = complexFields.data || []; + + return ( + <> +

+ {t("settings.complex_fields.tab")} - {niceName} +

+ , + }} + /> +
+ , + }} + rowKey="key" + /> + + {contextHolder} + + ); +} diff --git a/client/src/pages/settings/index.tsx b/client/src/pages/settings/index.tsx index 4393addfa..01c0b9bfa 100644 --- a/client/src/pages/settings/index.tsx +++ b/client/src/pages/settings/index.tsx @@ -5,6 +5,7 @@ import { Content } from "antd/es/layout/layout"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { Route, Routes, useNavigate } from "react-router"; +import { ComplexFieldsSettings } from "./complexFieldsSettings"; import { ExtraFieldsSettings } from "./extraFieldsSettings"; import { GeneralSettings } from "./generalSettings"; @@ -79,6 +80,28 @@ export const Settings = () => { }, ], }, + { + key: "complex", + label: t("settings.complex_fields.tab"), + icon: , + children: [ + { + label: t("spool.spool"), + key: "complex/spool", + icon: , + }, + { + label: t("filament.filament"), + key: "complex/filament", + icon: , + }, + { + label: t("vendor.vendor"), + key: "complex/vendor", + icon: , + }, + ], + }, ]} style={{ marginBottom: "1em", @@ -88,6 +111,7 @@ export const Settings = () => { } /> } /> + } /> diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts index 7cde38c05..c71fdcbde 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -19,6 +19,14 @@ export enum EntityType { spool = "spool", } +export enum ComplexFieldSurface { + show = "show", + edit = "edit", + list = "list", + action = "action", + derived = "derived", +} + export interface FieldParameters { name: string; order: number; @@ -34,6 +42,19 @@ export interface Field extends FieldParameters { entity_type: EntityType; } +export interface ComplexFieldDefinition { + key: string; + entity_type: EntityType; + name: string; + description: string; + enable_description: string; + surfaces: ComplexFieldSurface[]; +} + +export interface ComplexFieldState extends ComplexFieldDefinition { + enabled: boolean; +} + export function useGetFields(entity_type: EntityType) { return useQuery({ queryKey: ["fields", entity_type], @@ -134,3 +155,71 @@ export function useDeleteField(entity_type: EntityType) { }, }); } + +export function useGetComplexFields(entity_type: EntityType) { + return useQuery({ + queryKey: ["complexFields", entity_type], + queryFn: async () => { + const response = await fetch(`${getAPIURL()}/field/complex/${entity_type}`); + return response.json(); + }, + }); +} + +export function useSetComplexFieldEnabled(entity_type: EntityType) { + const queryClient = useQueryClient(); + + return useMutation< + ComplexFieldState[], + unknown, + { key: string; enabled: boolean }, + { previousFields?: ComplexFieldState[] } + >({ + mutationFn: async ({ key, enabled }) => { + const response = await fetch(`${getAPIURL()}/field/complex/${entity_type}/${key}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled }), + }); + + if (!response.ok) { + throw new Error((await response.json()).message); + } + + return response.json(); + }, + onMutate: async ({ key, enabled }) => { + await queryClient.cancelQueries({ + queryKey: ["complexFields", entity_type], + }); + + const previousFields = queryClient.getQueryData(["complexFields", entity_type]); + + queryClient.setQueryData(["complexFields", entity_type], (old) => + old?.map((field) => { + if (field.key !== key) { + return field; + } + return { + ...field, + enabled, + }; + }) || old, + ); + + return { previousFields }; + }, + onError: (_err, _newFields, context) => { + if (context?.previousFields) { + queryClient.setQueryData(["complexFields", entity_type], context.previousFields); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ["complexFields", entity_type], + }); + }, + }); +} diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py index 36e63d845..2da832ae9 100644 --- a/spoolman/api/v1/field.py +++ b/spoolman/api/v1/field.py @@ -8,6 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Message +from spoolman.complex_fields import ( + ComplexFieldState, + ComplexFieldToggleRequest, + get_complex_fields, + set_complex_field_enabled, +) from spoolman.database.database import get_db_session from spoolman.exceptions import ItemNotFoundError from spoolman.extra_fields import ( @@ -29,6 +35,49 @@ logger = logging.getLogger(__name__) +@router.get( + "/complex/{entity_type}", + name="Get complex fields", + description="Get all registered complex fields for a specific entity type, including enabled state.", + response_model_exclude_none=True, +) +async def get_complex( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this complex field is for")], +) -> list[ComplexFieldState]: + return await get_complex_fields(db, entity_type) + + +@router.post( + "/complex/{entity_type}/{key}", + name="Enable or disable complex field", + description=( + "Enable or disable a registered complex field for a specific entity type. " + "Returns the full list of registered complex fields for the entity type." + ), + response_model_exclude_none=True, + response_model=list[ComplexFieldState], + responses={404: {"model": Message}}, +) +async def set_complex( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this complex field is for")], + key: Annotated[str, Path(min_length=1, max_length=64, pattern="^[a-z0-9_]+$")], + body: ComplexFieldToggleRequest, +) -> list[ComplexFieldState] | JSONResponse: + try: + await set_complex_field_enabled(db, entity_type, key, body.enabled) + except ItemNotFoundError: + return JSONResponse( + status_code=404, + content=Message( + message=f"Complex field with key {key} does not exist for entity type {entity_type.name}", + ).dict(), + ) + + return await get_complex_fields(db, entity_type) + + @router.get( "/{entity_type}", name="Get extra fields", diff --git a/spoolman/complex_fields.py b/spoolman/complex_fields.py new file mode 100644 index 000000000..ecfa98588 --- /dev/null +++ b/spoolman/complex_fields.py @@ -0,0 +1,121 @@ +"""Code-defined complex field capabilities that can be enabled per entity type.""" + +import json +import logging +from enum import Enum + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.database import setting as db_setting +from spoolman.exceptions import ItemNotFoundError +from spoolman.extra_fields import EntityType +from spoolman.settings import parse_setting + +logger = logging.getLogger(__name__) + + +class ComplexFieldSurface(Enum): + """UI surfaces that a complex field can extend.""" + + show = "show" + edit = "edit" + list = "list" + action = "action" + derived = "derived" + + +class ComplexFieldDefinition(BaseModel): + """Code-defined description for a complex field capability.""" + + key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64) + entity_type: EntityType = Field(description="Entity type this complex field is for") + name: str = Field(description="Display name", min_length=1, max_length=128) + description: str = Field(description="Short description", min_length=1, max_length=512) + enable_description: str = Field(description="What enabling adds", min_length=1, max_length=512) + surfaces: list[ComplexFieldSurface] = Field(default_factory=list, description="Surfaces affected by this feature") + + +class ComplexFieldState(ComplexFieldDefinition): + """Complex field definition with its current enabled state.""" + + enabled: bool = Field(description="Whether the complex field is enabled") + + +class ComplexFieldToggleRequest(BaseModel): + """Request body for enabling or disabling a complex field.""" + + enabled: bool = Field(description="Whether the complex field should be enabled") + + +_complex_field_registry: dict[EntityType, list[ComplexFieldDefinition]] = {entity_type: [] for entity_type in EntityType} +_complex_field_cache: dict[EntityType, list[str]] = {} + + +def register_complex_field(complex_field: ComplexFieldDefinition) -> None: + """Register a complex field definition for future use.""" + existing = _complex_field_registry[complex_field.entity_type] + if any(field.key == complex_field.key for field in existing): + raise ValueError(f"Complex field {complex_field.key} is already registered for {complex_field.entity_type.name}.") + existing.append(complex_field) + existing.sort(key=lambda item: (item.name.lower(), item.key)) + + +def get_registered_complex_fields(entity_type: EntityType) -> list[ComplexFieldDefinition]: + """Get all registered complex field definitions for an entity type.""" + return list(_complex_field_registry[entity_type]) + + +async def get_enabled_complex_field_keys(db: AsyncSession, entity_type: EntityType) -> list[str]: + """Get the enabled complex field keys for an entity type.""" + if entity_type in _complex_field_cache: + return _complex_field_cache[entity_type] + + setting_def = parse_setting(f"complex_fields_{entity_type.name}") + try: + setting = await db_setting.get(db, setting_def) + setting_value = setting.value + except ItemNotFoundError: + setting_value = setting_def.default + + parsed = json.loads(setting_value) + if not isinstance(parsed, list): + logger.warning("Setting %s is not a list, using default.", setting_def.key) + parsed = [] + + enabled_keys = [value for value in parsed if isinstance(value, str)] + _complex_field_cache[entity_type] = enabled_keys + return enabled_keys + + +async def get_complex_fields(db: AsyncSession, entity_type: EntityType) -> list[ComplexFieldState]: + """Get all registered complex fields for an entity type, including enabled state.""" + enabled_keys = set(await get_enabled_complex_field_keys(db, entity_type)) + return [ + ComplexFieldState.model_validate( + { + **jsonable_encoder(definition), + "enabled": definition.key in enabled_keys, + }, + ) + for definition in get_registered_complex_fields(entity_type) + ] + + +async def set_complex_field_enabled(db: AsyncSession, entity_type: EntityType, key: str, enabled: bool) -> None: + """Enable or disable a registered complex field for an entity type.""" + registered = get_registered_complex_fields(entity_type) + if not any(field.key == key for field in registered): + raise ItemNotFoundError(f"Complex field with key {key} does not exist.") + + enabled_keys = await get_enabled_complex_field_keys(db, entity_type) + next_keys = [value for value in enabled_keys if value != key] + if enabled: + next_keys.append(key) + next_keys = sorted(set(next_keys)) + + setting_def = parse_setting(f"complex_fields_{entity_type.name}") + await db_setting.update(db=db, definition=setting_def, value=json.dumps(next_keys)) + _complex_field_cache[entity_type] = next_keys + diff --git a/spoolman/settings.py b/spoolman/settings.py index 0c6ce3193..8018e8b1a 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -68,6 +68,9 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([])) +register_setting("complex_fields_vendor", SettingType.ARRAY, json.dumps([])) +register_setting("complex_fields_filament", SettingType.ARRAY, json.dumps([])) +register_setting("complex_fields_spool", SettingType.ARRAY, json.dumps([])) register_setting("base_url", SettingType.STRING, json.dumps("")) register_setting("locations", SettingType.ARRAY, json.dumps([])) From 8efc5bcd4b8f586ef059b6bca95762c536cd5fa3 Mon Sep 17 00:00:00 2001 From: akira69 Date: Thu, 19 Mar 2026 08:44:35 -0500 Subject: [PATCH 2/3] feat(applications): rebrand Complex Fields to Applications with catalog UI - Rename spoolman/complex_fields.py to spoolman/applications.py All ComplexField* classes renamed to Application* Added icon and app_key fields to ApplicationDefinition - Add spoolman/demo_applications.py with 8 concept app stubs: Drying Tracker, Inventory Alerts, Print History, Weight Audit, QR Customization, Spool Count, Filament Textures, Storage Conditions - Wire register_demo_applications() into startup in main.py - Rename API endpoints: /field/complex/ to /field/application/ - Rename setting keys: complex_fields_* to applications_* - Add Alembic migration to rename existing setting rows in DB - Frontend: rename ComplexField* types/hooks to Application* in queryFields.ts - Frontend: remove Complex Fields from Settings page (menu + route) - Frontend: add Applications to sidebar navigation (AppstoreOutlined icon) - Frontend: new ApplicationCard compone- Frontend: new ApplicationCard compone- Frontend: new ApplicationCard compone- Frontend: new ApplicationCard compone- Frontend: new ApplicationCa en- Frontend: new ApplicationCard compone- Frontend: new Applicas to applications keys --- client/public/locales/en/common.json | 12 +- client/src/App.tsx | 11 ++ client/src/components/applicationCard.tsx | 87 +++++++++ .../src/pages/applications/detail/index.tsx | 176 ++++++++++++++++++ client/src/pages/applications/index.tsx | 125 +++++++++++++ .../pages/settings/complexFieldsSettings.tsx | 128 ------------- client/src/pages/settings/index.tsx | 24 --- client/src/utils/queryFields.ts | 58 +++--- ...6_rename_complex_fields_to_applications.py | 35 ++++ spoolman/api/v1/field.py | 48 ++--- spoolman/applications.py | 126 +++++++++++++ spoolman/complex_fields.py | 121 ------------ spoolman/demo_applications.py | 159 ++++++++++++++++ spoolman/main.py | 4 + spoolman/settings.py | 6 +- 15 files changed, 789 insertions(+), 331 deletions(-) create mode 100644 client/src/components/applicationCard.tsx create mode 100644 client/src/pages/applications/detail/index.tsx create mode 100644 client/src/pages/applications/index.tsx delete mode 100644 client/src/pages/settings/complexFieldsSettings.tsx create mode 100644 migrations/versions/2025_01_01_0000-a1b2c3d4e5f6_rename_complex_fields_to_applications.py create mode 100644 spoolman/applications.py delete mode 100644 spoolman/complex_fields.py create mode 100644 spoolman/demo_applications.py diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 1cd73f3da..656a74c56 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -355,10 +355,11 @@ "delete_confirm": "Delete field {{name}}?", "delete_confirm_description": "This will delete the field and all associated data for all entities." }, - "complex_fields": { - "tab": "Complex Fields", - "description": "

Complex fields add optional, pre-defined behaviors beyond standard extra fields.

Enabling one can add specialized display, actions, calculated values, or list columns for the selected entity. Disabled features remain hidden so the standard UI stays simpler.

Each entry below states exactly what enabling it adds. This framework can stay empty until a specific advanced feature is installed.

", - "empty": "No complex fields are currently registered for this entity.", + "applications": { + "tab": "Applications", + "description": "

Applications add optional, pre-defined behaviors to your Spoolman instance.

Enabling an application can add specialized display, actions, calculated values, or list columns. Disabled applications remain hidden so the standard UI stays simpler.

", + "empty": "No applications are currently available.", + "all": "All", "columns": { "name": "Name", "description": "Feature", @@ -391,6 +392,9 @@ "locations": { "list": "Locations | Spoolman" }, + "applications": { + "list": "Applications | Spoolman" + }, "filament": { "list": "Filaments | Spoolman", "show": "#{{id}} Show Filament | Spoolman", diff --git a/client/src/App.tsx b/client/src/App.tsx index d907b8ee1..f1c1c5b03 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,7 @@ import { ErrorComponent } from "@refinedev/antd"; import "@refinedev/antd/dist/reset.css"; import { + AppstoreOutlined, FileOutlined, HighlightOutlined, HomeOutlined, @@ -150,6 +151,14 @@ function App() { icon: , }, }, + { + name: "applications", + list: "/applications", + meta: { + canDelete: false, + icon: , + }, + }, { name: "settings", list: "/settings", @@ -225,6 +234,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/client/src/components/applicationCard.tsx b/client/src/components/applicationCard.tsx new file mode 100644 index 000000000..ae2e04775 --- /dev/null +++ b/client/src/components/applicationCard.tsx @@ -0,0 +1,87 @@ +import { useTranslate } from "@refinedev/core"; +import { Avatar, Badge, Button, Card, Space, Switch, Tag, Typography } from "antd"; +import { useNavigate } from "react-router"; +import { ApplicationState, EntityType } from "../utils/queryFields"; + +interface ApplicationCardProps { + appKey: string; + name: string; + description: string; + icon: string | null; + states: ApplicationState[]; + onToggle: (entityType: EntityType, key: string, enabled: boolean) => Promise; + toggling: { entityType: EntityType; key: string } | null; +} + +const ENTITY_COLORS: Record = { + [EntityType.spool]: "blue", + [EntityType.filament]: "green", + [EntityType.vendor]: "orange", +}; + +export function ApplicationCard({ appKey, name, description, icon, states, onToggle, toggling }: ApplicationCardProps) { + const t = useTranslate(); + const navigate = useNavigate(); + + const anyEnabled = states.some((s) => s.enabled); + const allEnabled = states.length > 0 && states.every((s) => s.enabled); + + const badgeStatus = allEnabled ? "success" : anyEnabled ? "warning" : "default"; + const badgeText = allEnabled + ? t("applications.columns.enabled") + : anyEnabled + ? t("applications.columns.enabled") + : t("applications.all"); + + const avatarContent = icon ?? name.charAt(0).toUpperCase(); + + return ( + + navigate(`/applications/${appKey}`)}> + Configure + , + ]} + > + + {avatarContent} + + } + title={{name}} + description={ + + {description} + + } + /> + + {states.map((state) => ( + + {t(`${state.entity_type}.${state.entity_type}`)} + onToggle(state.entity_type, state.key, checked)} + onClick={(_, e) => e.stopPropagation()} + style={{ marginLeft: 4 }} + /> + + ))} + + + + ); +} diff --git a/client/src/pages/applications/detail/index.tsx b/client/src/pages/applications/detail/index.tsx new file mode 100644 index 000000000..f32f7ea98 --- /dev/null +++ b/client/src/pages/applications/detail/index.tsx @@ -0,0 +1,176 @@ +import { LeftOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Avatar, Button, Col, Empty, Form, Row, Space, Switch, Table, Tag, Typography, message, theme } from "antd"; +import { ColumnType } from "antd/es/table"; +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { + ApplicationState, + ApplicationSurface, + EntityType, + useGetApplications, + useSetApplicationEnabled, +} from "../../../utils/queryFields"; + +const ENTITY_COLORS: Record = { + [EntityType.spool]: "blue", + [EntityType.filament]: "green", + [EntityType.vendor]: "orange", +}; + +export const ApplicationDetail = () => { + const { appKey } = useParams<{ appKey: string }>(); + const t = useTranslate(); + const { token } = theme.useToken(); + const navigate = useNavigate(); + const [messageApi, contextHolder] = message.useMessage(); + const [pendingKey, setPendingKey] = useState(null); + + const spoolApps = useGetApplications(EntityType.spool); + const filamentApps = useGetApplications(EntityType.filament); + const vendorApps = useGetApplications(EntityType.vendor); + + const setSpoolEnabled = useSetApplicationEnabled(EntityType.spool); + const setFilamentEnabled = useSetApplicationEnabled(EntityType.filament); + const setVendorEnabled = useSetApplicationEnabled(EntityType.vendor); + + const states = useMemo(() => { + const all: ApplicationState[] = [ + ...(spoolApps.data ?? []), + ...(filamentApps.data ?? []), + ...(vendorApps.data ?? []), + ]; + return all.filter((s) => (s.app_key ?? s.key) === appKey); + }, [spoolApps.data, filamentApps.data, vendorApps.data, appKey]); + + const isLoading = spoolApps.isLoading || filamentApps.isLoading || vendorApps.isLoading; + const first = states[0]; + + const handleToggle = async (record: ApplicationState, enabled: boolean) => { + setPendingKey(`${record.entity_type}:${record.key}`); + try { + const setter = + record.entity_type === EntityType.spool + ? setSpoolEnabled + : record.entity_type === EntityType.filament + ? setFilamentEnabled + : setVendorEnabled; + await setter.mutateAsync({ key: record.key, enabled }); + messageApi.success( + t(enabled ? "applications.messages.enabled" : "applications.messages.disabled", { name: record.name }), + ); + } catch (err) { + if (err instanceof Error) { + messageApi.error(err.message); + } + } finally { + setPendingKey(null); + } + }; + + const columns: ColumnType[] = [ + { + title: t("applications.columns.name"), + dataIndex: "name", + key: "name", + width: "18%", + }, + { + title: "Entity", + dataIndex: "entity_type", + key: "entity_type", + width: "12%", + render: (et: EntityType) => {t(`${et}.${et}`)}, + }, + { + title: t("applications.columns.description"), + dataIndex: "description", + key: "description", + width: "24%", + render: (value: string) => ( + {value} + ), + }, + { + title: t("applications.columns.enable_description"), + dataIndex: "enable_description", + key: "enable_description", + width: "28%", + render: (value: string) => ( + {value} + ), + }, + { + title: t("applications.columns.surfaces"), + dataIndex: "surfaces", + key: "surfaces", + width: "10%", + render: (surfaces: ApplicationSurface[]) => ( + + {surfaces.map((s) => ( + {t(`applications.surfaces.${s}`)} + ))} + + ), + }, + { + title: t("applications.columns.enabled"), + dataIndex: "enabled", + key: "enabled", + align: "center", + width: "8%", + render: (enabled: boolean, record) => ( + handleToggle(record, checked)} + /> + ), + }, + ]; + + return ( +
+ {contextHolder} + + + {first && ( + +
+ + {first.icon ?? first.name.charAt(0).toUpperCase()} + + + + + {first.name} + + {first.description} + + + )} + + +
`${r.entity_type}:${r.key}`} + locale={{ + emptyText: , + }} + /> + + + ); +}; + +export default ApplicationDetail; diff --git a/client/src/pages/applications/index.tsx b/client/src/pages/applications/index.tsx new file mode 100644 index 000000000..3eed17f43 --- /dev/null +++ b/client/src/pages/applications/index.tsx @@ -0,0 +1,125 @@ +import { useTranslate } from "@refinedev/core"; +import { Col, Empty, Row, Segmented, Spin, Typography, message, theme } from "antd"; +import { useMemo, useState } from "react"; +import { ApplicationCard } from "../../components/applicationCard"; +import { ApplicationState, EntityType, useGetApplications, useSetApplicationEnabled } from "../../utils/queryFields"; + +type FilterMode = "all" | "enabled" | "disabled"; + +interface TogglingState { + entityType: EntityType; + key: string; +} + +export const Applications = () => { + const t = useTranslate(); + const { token } = theme.useToken(); + const [messageApi, contextHolder] = message.useMessage(); + const [filter, setFilter] = useState("all"); + const [toggling, setToggling] = useState(null); + + const spoolApps = useGetApplications(EntityType.spool); + const filamentApps = useGetApplications(EntityType.filament); + const vendorApps = useGetApplications(EntityType.vendor); + + const setSpoolEnabled = useSetApplicationEnabled(EntityType.spool); + const setFilamentEnabled = useSetApplicationEnabled(EntityType.filament); + const setVendorEnabled = useSetApplicationEnabled(EntityType.vendor); + + const isLoading = spoolApps.isLoading || filamentApps.isLoading || vendorApps.isLoading; + + // Merge all states by app_key, falling back to key + const groupedApps = useMemo(() => { + const all: ApplicationState[] = [ + ...(spoolApps.data ?? []), + ...(filamentApps.data ?? []), + ...(vendorApps.data ?? []), + ]; + + const byAppKey = new Map(); + for (const state of all) { + const groupKey = state.app_key ?? state.key; + if (!byAppKey.has(groupKey)) { + byAppKey.set(groupKey, []); + } + byAppKey.get(groupKey)!.push(state); + } + return byAppKey; + }, [spoolApps.data, filamentApps.data, vendorApps.data]); + + const filteredGroups = useMemo(() => { + const entries = Array.from(groupedApps.entries()); + if (filter === "enabled") return entries.filter(([, states]) => states.some((s) => s.enabled)); + if (filter === "disabled") return entries.filter(([, states]) => states.every((s) => !s.enabled)); + return entries; + }, [groupedApps, filter]); + + const handleToggle = async (entityType: EntityType, key: string, enabled: boolean) => { + setToggling({ entityType, key }); + try { + const setter = + entityType === EntityType.spool + ? setSpoolEnabled + : entityType === EntityType.filament + ? setFilamentEnabled + : setVendorEnabled; + await setter.mutateAsync({ key, enabled }); + messageApi.success( + enabled + ? t("applications.messages.enabled", { name: key }) + : t("applications.messages.disabled", { name: key }), + ); + } catch (err) { + if (err instanceof Error) { + messageApi.error(err.message); + } + } finally { + setToggling(null); + } + }; + + return ( +
+ {contextHolder} +

{t("applications.tab")}

+ + {t("applications.description")} + + setFilter(val as FilterMode)} + style={{ marginBottom: 24 }} + /> + {isLoading ? ( + + ) : filteredGroups.length === 0 ? ( + + ) : ( + + {filteredGroups.map(([appKey, states]) => { + const first = states[0]; + return ( +
+ + + ); + })} + + )} + + ); +}; + +export default Applications; diff --git a/client/src/pages/settings/complexFieldsSettings.tsx b/client/src/pages/settings/complexFieldsSettings.tsx deleted file mode 100644 index 804c6d1f1..000000000 --- a/client/src/pages/settings/complexFieldsSettings.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useTranslate } from "@refinedev/core"; -import { Empty, Form, Space, Switch, Table, Tag, Typography, message } from "antd"; -import { ColumnType } from "antd/es/table"; -import { useState } from "react"; -import { Trans } from "react-i18next"; -import { useParams } from "react-router"; -import { - ComplexFieldState, - ComplexFieldSurface, - EntityType, - useGetComplexFields, - useSetComplexFieldEnabled, -} from "../../utils/queryFields"; - -export function ComplexFieldsSettings() { - const { entityType } = useParams<{ entityType: EntityType }>(); - const t = useTranslate(); - const [messageApi, contextHolder] = message.useMessage(); - const [pendingKey, setPendingKey] = useState(null); - const complexFields = useGetComplexFields(entityType as EntityType); - const setComplexFieldEnabled = useSetComplexFieldEnabled(entityType as EntityType); - - const niceName = t(`${entityType}.${entityType}`); - - const onToggle = async (record: ComplexFieldState, enabled: boolean) => { - try { - setPendingKey(record.key); - await setComplexFieldEnabled.mutateAsync({ - key: record.key, - enabled, - }); - messageApi.success( - t(enabled ? "settings.complex_fields.messages.enabled" : "settings.complex_fields.messages.disabled", { - name: record.name, - }), - ); - } catch (errInfo) { - if (errInfo instanceof Error) { - messageApi.error(errInfo.message); - } - } finally { - setPendingKey(null); - } - }; - - const columns: ColumnType[] = [ - { - title: t("settings.complex_fields.columns.name"), - dataIndex: "name", - key: "name", - width: "18%", - }, - { - title: t("settings.complex_fields.columns.description"), - dataIndex: "description", - key: "description", - width: "24%", - render: (value: string) => ( - {value} - ), - }, - { - title: t("settings.complex_fields.columns.enable_description"), - dataIndex: "enable_description", - key: "enable_description", - width: "32%", - render: (value: string) => ( - {value} - ), - }, - { - title: t("settings.complex_fields.columns.surfaces"), - dataIndex: "surfaces", - key: "surfaces", - width: "16%", - render: (surfaces: ComplexFieldSurface[]) => ( - - {surfaces.map((surface) => ( - {t(`settings.complex_fields.surfaces.${surface}`)} - ))} - - ), - }, - { - title: t("settings.complex_fields.columns.enabled"), - dataIndex: "enabled", - key: "enabled", - align: "center", - width: "10%", - render: (enabled: boolean, record) => ( - onToggle(record, checked)} - /> - ), - }, - ]; - - const rows = complexFields.data || []; - - return ( - <> -

- {t("settings.complex_fields.tab")} - {niceName} -

- , - }} - /> -
-
, - }} - rowKey="key" - /> - - {contextHolder} - - ); -} diff --git a/client/src/pages/settings/index.tsx b/client/src/pages/settings/index.tsx index 01c0b9bfa..4393addfa 100644 --- a/client/src/pages/settings/index.tsx +++ b/client/src/pages/settings/index.tsx @@ -5,7 +5,6 @@ import { Content } from "antd/es/layout/layout"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { Route, Routes, useNavigate } from "react-router"; -import { ComplexFieldsSettings } from "./complexFieldsSettings"; import { ExtraFieldsSettings } from "./extraFieldsSettings"; import { GeneralSettings } from "./generalSettings"; @@ -80,28 +79,6 @@ export const Settings = () => { }, ], }, - { - key: "complex", - label: t("settings.complex_fields.tab"), - icon: , - children: [ - { - label: t("spool.spool"), - key: "complex/spool", - icon: , - }, - { - label: t("filament.filament"), - key: "complex/filament", - icon: , - }, - { - label: t("vendor.vendor"), - key: "complex/vendor", - icon: , - }, - ], - }, ]} style={{ marginBottom: "1em", @@ -111,7 +88,6 @@ export const Settings = () => { } /> } /> - } /> diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts index c71fdcbde..531196166 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -19,7 +19,7 @@ export enum EntityType { spool = "spool", } -export enum ComplexFieldSurface { +export enum ApplicationSurface { show = "show", edit = "edit", list = "list", @@ -42,16 +42,18 @@ export interface Field extends FieldParameters { entity_type: EntityType; } -export interface ComplexFieldDefinition { +export interface ApplicationDefinition { key: string; + app_key: string | null; + icon: string | null; entity_type: EntityType; name: string; description: string; enable_description: string; - surfaces: ComplexFieldSurface[]; + surfaces: ApplicationSurface[]; } -export interface ComplexFieldState extends ComplexFieldDefinition { +export interface ApplicationState extends ApplicationDefinition { enabled: boolean; } @@ -156,27 +158,27 @@ export function useDeleteField(entity_type: EntityType) { }); } -export function useGetComplexFields(entity_type: EntityType) { - return useQuery({ - queryKey: ["complexFields", entity_type], +export function useGetApplications(entity_type: EntityType) { + return useQuery({ + queryKey: ["applications", entity_type], queryFn: async () => { - const response = await fetch(`${getAPIURL()}/field/complex/${entity_type}`); + const response = await fetch(`${getAPIURL()}/field/application/${entity_type}`); return response.json(); }, }); } -export function useSetComplexFieldEnabled(entity_type: EntityType) { +export function useSetApplicationEnabled(entity_type: EntityType) { const queryClient = useQueryClient(); return useMutation< - ComplexFieldState[], + ApplicationState[], unknown, { key: string; enabled: boolean }, - { previousFields?: ComplexFieldState[] } + { previousFields?: ApplicationState[] } >({ mutationFn: async ({ key, enabled }) => { - const response = await fetch(`${getAPIURL()}/field/complex/${entity_type}/${key}`, { + const response = await fetch(`${getAPIURL()}/field/application/${entity_type}/${key}`, { method: "POST", headers: { "Content-Type": "application/json", @@ -192,33 +194,35 @@ export function useSetComplexFieldEnabled(entity_type: EntityType) { }, onMutate: async ({ key, enabled }) => { await queryClient.cancelQueries({ - queryKey: ["complexFields", entity_type], + queryKey: ["applications", entity_type], }); - const previousFields = queryClient.getQueryData(["complexFields", entity_type]); - - queryClient.setQueryData(["complexFields", entity_type], (old) => - old?.map((field) => { - if (field.key !== key) { - return field; - } - return { - ...field, - enabled, - }; - }) || old, + const previousFields = queryClient.getQueryData(["applications", entity_type]); + + queryClient.setQueryData( + ["applications", entity_type], + (old) => + old?.map((field) => { + if (field.key !== key) { + return field; + } + return { + ...field, + enabled, + }; + }) || old, ); return { previousFields }; }, onError: (_err, _newFields, context) => { if (context?.previousFields) { - queryClient.setQueryData(["complexFields", entity_type], context.previousFields); + queryClient.setQueryData(["applications", entity_type], context.previousFields); } }, onSettled: () => { queryClient.invalidateQueries({ - queryKey: ["complexFields", entity_type], + queryKey: ["applications", entity_type], }); }, }); diff --git a/migrations/versions/2025_01_01_0000-a1b2c3d4e5f6_rename_complex_fields_to_applications.py b/migrations/versions/2025_01_01_0000-a1b2c3d4e5f6_rename_complex_fields_to_applications.py new file mode 100644 index 000000000..561e424bd --- /dev/null +++ b/migrations/versions/2025_01_01_0000-a1b2c3d4e5f6_rename_complex_fields_to_applications.py @@ -0,0 +1,35 @@ +"""rename_complex_fields_to_applications. + +Revision ID: a1b2c3d4e5f6 +Revises: 415a8f855e14 +Create Date: 2025-01-01 00:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "415a8f855e14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Rename complex_fields_* setting keys to applications_* keys.""" + connection = op.get_bind() + connection.execute(sa.text("UPDATE setting SET key = 'applications_vendor' WHERE key = 'complex_fields_vendor'")) + connection.execute( + sa.text("UPDATE setting SET key = 'applications_filament' WHERE key = 'complex_fields_filament'") + ) + connection.execute(sa.text("UPDATE setting SET key = 'applications_spool' WHERE key = 'complex_fields_spool'")) + + +def downgrade() -> None: + """Revert applications_* setting keys back to complex_fields_* keys.""" + connection = op.get_bind() + connection.execute(sa.text("UPDATE setting SET key = 'complex_fields_vendor' WHERE key = 'applications_vendor'")) + connection.execute( + sa.text("UPDATE setting SET key = 'complex_fields_filament' WHERE key = 'applications_filament'") + ) + connection.execute(sa.text("UPDATE setting SET key = 'complex_fields_spool' WHERE key = 'applications_spool'")) diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py index 2da832ae9..1d2150e61 100644 --- a/spoolman/api/v1/field.py +++ b/spoolman/api/v1/field.py @@ -8,11 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Message -from spoolman.complex_fields import ( - ComplexFieldState, - ComplexFieldToggleRequest, - get_complex_fields, - set_complex_field_enabled, +from spoolman.applications import ( + ApplicationState, + ApplicationToggleRequest, + get_applications, + set_application_enabled, ) from spoolman.database.database import get_db_session from spoolman.exceptions import ItemNotFoundError @@ -36,46 +36,46 @@ @router.get( - "/complex/{entity_type}", - name="Get complex fields", - description="Get all registered complex fields for a specific entity type, including enabled state.", + "/application/{entity_type}", + name="Get applications", + description="Get all registered applications for a specific entity type, including enabled state.", response_model_exclude_none=True, ) -async def get_complex( +async def get_application_list( db: Annotated[AsyncSession, Depends(get_db_session)], - entity_type: Annotated[EntityType, Path(description="Entity type this complex field is for")], -) -> list[ComplexFieldState]: - return await get_complex_fields(db, entity_type) + entity_type: Annotated[EntityType, Path(description="Entity type this application is for")], +) -> list[ApplicationState]: + return await get_applications(db, entity_type) @router.post( - "/complex/{entity_type}/{key}", - name="Enable or disable complex field", + "/application/{entity_type}/{key}", + name="Enable or disable application", description=( - "Enable or disable a registered complex field for a specific entity type. " - "Returns the full list of registered complex fields for the entity type." + "Enable or disable a registered application for a specific entity type. " + "Returns the full list of registered applications for the entity type." ), response_model_exclude_none=True, - response_model=list[ComplexFieldState], + response_model=list[ApplicationState], responses={404: {"model": Message}}, ) -async def set_complex( +async def set_application( db: Annotated[AsyncSession, Depends(get_db_session)], - entity_type: Annotated[EntityType, Path(description="Entity type this complex field is for")], + entity_type: Annotated[EntityType, Path(description="Entity type this application is for")], key: Annotated[str, Path(min_length=1, max_length=64, pattern="^[a-z0-9_]+$")], - body: ComplexFieldToggleRequest, -) -> list[ComplexFieldState] | JSONResponse: + body: ApplicationToggleRequest, +) -> list[ApplicationState] | JSONResponse: try: - await set_complex_field_enabled(db, entity_type, key, body.enabled) + await set_application_enabled(db, entity_type, key, enabled=body.enabled) except ItemNotFoundError: return JSONResponse( status_code=404, content=Message( - message=f"Complex field with key {key} does not exist for entity type {entity_type.name}", + message=f"Application with key {key} does not exist for entity type {entity_type.name}", ).dict(), ) - return await get_complex_fields(db, entity_type) + return await get_applications(db, entity_type) @router.get( diff --git a/spoolman/applications.py b/spoolman/applications.py new file mode 100644 index 000000000..93c1e6289 --- /dev/null +++ b/spoolman/applications.py @@ -0,0 +1,126 @@ +"""Optional application capabilities that can be enabled per entity type.""" + +import json +import logging +from enum import Enum + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.database import setting as db_setting +from spoolman.exceptions import ItemNotFoundError +from spoolman.extra_fields import EntityType +from spoolman.settings import parse_setting + +logger = logging.getLogger(__name__) + + +class ApplicationSurface(Enum): + """UI surfaces that an application can extend.""" + + show = "show" + edit = "edit" + list = "list" + action = "action" + derived = "derived" + + +class ApplicationDefinition(BaseModel): + """Code-defined description for an optional application capability.""" + + key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64) + app_key: str | None = Field( + default=None, description="Groups multi-entity registrations under one app (defaults to key)" + ) + icon: str | None = Field(default=None, description="Emoji icon shown in the applications catalog (e.g. '🔔')") + entity_type: EntityType = Field(description="Entity type this application is for") + name: str = Field(description="Display name", min_length=1, max_length=128) + description: str = Field(description="Short description", min_length=1, max_length=512) + enable_description: str = Field(description="What enabling adds", min_length=1, max_length=512) + surfaces: list[ApplicationSurface] = Field( + default_factory=list, description="Surfaces affected by this application" + ) + + +class ApplicationState(ApplicationDefinition): + """Application definition with its current enabled state.""" + + enabled: bool = Field(description="Whether the application is enabled") + + +class ApplicationToggleRequest(BaseModel): + """Request body for enabling or disabling an application.""" + + enabled: bool = Field(description="Whether the application should be enabled") + + +_application_registry: dict[EntityType, list[ApplicationDefinition]] = {entity_type: [] for entity_type in EntityType} +_application_cache: dict[EntityType, list[str]] = {} + + +def register_application(application: ApplicationDefinition) -> None: + """Register an application definition for future use.""" + existing = _application_registry[application.entity_type] + if any(app.key == application.key for app in existing): + raise ValueError(f"Application {application.key} is already registered for {application.entity_type.name}.") + existing.append(application) + existing.sort(key=lambda item: (item.name.lower(), item.key)) + + +def get_registered_applications(entity_type: EntityType) -> list[ApplicationDefinition]: + """Get all registered application definitions for an entity type.""" + return list(_application_registry[entity_type]) + + +async def get_enabled_application_keys(db: AsyncSession, entity_type: EntityType) -> list[str]: + """Get the enabled application keys for an entity type.""" + if entity_type in _application_cache: + return _application_cache[entity_type] + + setting_def = parse_setting(f"applications_{entity_type.name}") + try: + setting = await db_setting.get(db, setting_def) + setting_value = setting.value + except ItemNotFoundError: + setting_value = setting_def.default + + parsed = json.loads(setting_value) + if not isinstance(parsed, list): + logger.warning("Setting %s is not a list, using default.", setting_def.key) + parsed = [] + + enabled_keys = [value for value in parsed if isinstance(value, str)] + _application_cache[entity_type] = enabled_keys + return enabled_keys + + +async def get_applications(db: AsyncSession, entity_type: EntityType) -> list[ApplicationState]: + """Get all registered applications for an entity type, including enabled state.""" + enabled_keys = set(await get_enabled_application_keys(db, entity_type)) + return [ + ApplicationState.model_validate( + { + **jsonable_encoder(definition), + "enabled": definition.key in enabled_keys, + }, + ) + for definition in get_registered_applications(entity_type) + ] + + +async def set_application_enabled(db: AsyncSession, entity_type: EntityType, key: str, *, enabled: bool) -> None: + """Enable or disable a registered application for an entity type.""" + registered = get_registered_applications(entity_type) + if not any(app.key == key for app in registered): + raise ItemNotFoundError(f"Application with key {key} does not exist.") + + enabled_keys = await get_enabled_application_keys(db, entity_type) + next_keys = [value for value in enabled_keys if value != key] + if enabled: + next_keys.append(key) + next_keys = sorted(set(next_keys)) + + setting_def = parse_setting(f"applications_{entity_type.name}") + await db_setting.update(db=db, definition=setting_def, value=json.dumps(next_keys)) + _application_cache[entity_type] = next_keys diff --git a/spoolman/complex_fields.py b/spoolman/complex_fields.py deleted file mode 100644 index ecfa98588..000000000 --- a/spoolman/complex_fields.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Code-defined complex field capabilities that can be enabled per entity type.""" - -import json -import logging -from enum import Enum - -from fastapi.encoders import jsonable_encoder -from pydantic import BaseModel, Field -from sqlalchemy.ext.asyncio import AsyncSession - -from spoolman.database import setting as db_setting -from spoolman.exceptions import ItemNotFoundError -from spoolman.extra_fields import EntityType -from spoolman.settings import parse_setting - -logger = logging.getLogger(__name__) - - -class ComplexFieldSurface(Enum): - """UI surfaces that a complex field can extend.""" - - show = "show" - edit = "edit" - list = "list" - action = "action" - derived = "derived" - - -class ComplexFieldDefinition(BaseModel): - """Code-defined description for a complex field capability.""" - - key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64) - entity_type: EntityType = Field(description="Entity type this complex field is for") - name: str = Field(description="Display name", min_length=1, max_length=128) - description: str = Field(description="Short description", min_length=1, max_length=512) - enable_description: str = Field(description="What enabling adds", min_length=1, max_length=512) - surfaces: list[ComplexFieldSurface] = Field(default_factory=list, description="Surfaces affected by this feature") - - -class ComplexFieldState(ComplexFieldDefinition): - """Complex field definition with its current enabled state.""" - - enabled: bool = Field(description="Whether the complex field is enabled") - - -class ComplexFieldToggleRequest(BaseModel): - """Request body for enabling or disabling a complex field.""" - - enabled: bool = Field(description="Whether the complex field should be enabled") - - -_complex_field_registry: dict[EntityType, list[ComplexFieldDefinition]] = {entity_type: [] for entity_type in EntityType} -_complex_field_cache: dict[EntityType, list[str]] = {} - - -def register_complex_field(complex_field: ComplexFieldDefinition) -> None: - """Register a complex field definition for future use.""" - existing = _complex_field_registry[complex_field.entity_type] - if any(field.key == complex_field.key for field in existing): - raise ValueError(f"Complex field {complex_field.key} is already registered for {complex_field.entity_type.name}.") - existing.append(complex_field) - existing.sort(key=lambda item: (item.name.lower(), item.key)) - - -def get_registered_complex_fields(entity_type: EntityType) -> list[ComplexFieldDefinition]: - """Get all registered complex field definitions for an entity type.""" - return list(_complex_field_registry[entity_type]) - - -async def get_enabled_complex_field_keys(db: AsyncSession, entity_type: EntityType) -> list[str]: - """Get the enabled complex field keys for an entity type.""" - if entity_type in _complex_field_cache: - return _complex_field_cache[entity_type] - - setting_def = parse_setting(f"complex_fields_{entity_type.name}") - try: - setting = await db_setting.get(db, setting_def) - setting_value = setting.value - except ItemNotFoundError: - setting_value = setting_def.default - - parsed = json.loads(setting_value) - if not isinstance(parsed, list): - logger.warning("Setting %s is not a list, using default.", setting_def.key) - parsed = [] - - enabled_keys = [value for value in parsed if isinstance(value, str)] - _complex_field_cache[entity_type] = enabled_keys - return enabled_keys - - -async def get_complex_fields(db: AsyncSession, entity_type: EntityType) -> list[ComplexFieldState]: - """Get all registered complex fields for an entity type, including enabled state.""" - enabled_keys = set(await get_enabled_complex_field_keys(db, entity_type)) - return [ - ComplexFieldState.model_validate( - { - **jsonable_encoder(definition), - "enabled": definition.key in enabled_keys, - }, - ) - for definition in get_registered_complex_fields(entity_type) - ] - - -async def set_complex_field_enabled(db: AsyncSession, entity_type: EntityType, key: str, enabled: bool) -> None: - """Enable or disable a registered complex field for an entity type.""" - registered = get_registered_complex_fields(entity_type) - if not any(field.key == key for field in registered): - raise ItemNotFoundError(f"Complex field with key {key} does not exist.") - - enabled_keys = await get_enabled_complex_field_keys(db, entity_type) - next_keys = [value for value in enabled_keys if value != key] - if enabled: - next_keys.append(key) - next_keys = sorted(set(next_keys)) - - setting_def = parse_setting(f"complex_fields_{entity_type.name}") - await db_setting.update(db=db, definition=setting_def, value=json.dumps(next_keys)) - _complex_field_cache[entity_type] = next_keys - diff --git a/spoolman/demo_applications.py b/spoolman/demo_applications.py new file mode 100644 index 000000000..3da23e742 --- /dev/null +++ b/spoolman/demo_applications.py @@ -0,0 +1,159 @@ +"""Concept application stubs registered at startup for UI testing and demonstration. + +These applications are descriptive only — they have no backend logic and are all +disabled by default. They populate the Applications catalog so the UI is immediately +testable and visually compelling. + +Future phases will implement actual functionality for each of these concepts. +See: https://github.com/akira69/spoolman-workspace/blob/main/Agent%20Memories/pr878-summary.md +""" + +from spoolman.applications import ApplicationDefinition, ApplicationSurface, register_application +from spoolman.extra_fields import EntityType + + +def register_demo_applications() -> None: + """Register all demo/concept application stubs.""" + register_application( + ApplicationDefinition( + key="spool_count", + app_key="spool_count", + icon="🔢", + entity_type=EntityType.filament, + name="Spool Count", + description="Shows the number of spools associated with each filament directly in the filament list.", + enable_description="Adds a Spools column to the filament list showing total and available spool counts.", + surfaces=[ApplicationSurface.list, ApplicationSurface.derived], + ) + ) + + register_application( + ApplicationDefinition( + key="inventory_alerts", + app_key="inventory_alerts", + icon="🔔", + entity_type=EntityType.spool, + name="Inventory Alerts", + description=( + "Warns when a spool's remaining weight drops below a configurable threshold, " + "helping you reorder before running out." + ), + enable_description="Adds alert badges to low-stock spool rows and a summary warning on the dashboard.", + surfaces=[ApplicationSurface.list, ApplicationSurface.action], + ) + ) + + register_application( + ApplicationDefinition( + key="drying_tracker", + app_key="drying_tracker", + icon="🌡️", + entity_type=EntityType.spool, + name="Drying Tracker", + description=( + "Record drying cycles for hygroscopic filaments. " + "Tracks temperature, duration, and the date each spool was last dried." + ), + enable_description=( + "Adds drying cycle history to spool detail pages and a drying status badge in the spool list." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.list], + ) + ) + + register_application( + ApplicationDefinition( + key="print_history", + app_key="print_history", + icon="🖨️", + entity_type=EntityType.spool, + name="Print History", + description=( + "Log which files were printed with each spool, along with estimated weight consumed per print job." + ), + enable_description=( + "Adds a print history tab to spool detail pages and shows a running consumed-weight total." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.action], + ) + ) + + register_application( + ApplicationDefinition( + key="weight_audit", + app_key="weight_audit", + icon="⚖️", + entity_type=EntityType.spool, + name="Weight Audit Trail", + description=( + "Maintains a timestamped log of every weight change made to a spool, " + "making it easy to track consumption over time." + ), + enable_description="Adds a weight history tab to spool detail pages with a consumption graph.", + surfaces=[ApplicationSurface.show, ApplicationSurface.list], + ) + ) + + register_application( + ApplicationDefinition( + key="qr_customization", + app_key="qr_customization", + icon="📱", + entity_type=EntityType.spool, + name="QR Code Customization", + description=( + "Choose which spool and filament fields are encoded in the label QR code. " + "Supports NFC tags and Home Assistant integrations." + ), + enable_description="Adds a QR field configuration panel to spool settings and label printing options.", + surfaces=[ApplicationSurface.action], + ) + ) + + register_application( + ApplicationDefinition( + key="filament_textures", + app_key="filament_textures", + icon="🎨", + entity_type=EntityType.filament, + name="Filament Textures", + description=( + "Add texture classification to filaments: Matte, Silk, Glossy, Sparkle, Wood-fill, and others. " + "Makes it easy to filter and search by surface finish." + ), + enable_description=( + "Adds a Texture field to filament create/edit forms " + "and enables texture-based filtering in the filament list." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.list], + ) + ) + + register_application( + ApplicationDefinition( + key="storage_conditions", + app_key="storage_conditions", + icon="🏠", + entity_type=EntityType.spool, + name="Storage Conditions", + description=( + "Log where each spool is stored and optionally record ambient humidity " + "and temperature readings at the storage location." + ), + enable_description="Adds storage location and conditions fields to spool detail and edit pages.", + surfaces=[ApplicationSurface.show, ApplicationSurface.edit], + ) + ) + + # Future application ideas (not yet implemented): + # + # - Compatibility Notes: which printer/nozzle/settings work best per filament + # - Purchase Tracker: order IDs, receipts, price paid per spool (#292 related) + # - Reorder Automation: generate shopping list when stock drops below threshold (#834) + # - Color Matching Log: record print result vs expected color per spool + # - Filament Aging: opened-date tracking + shelf-life expiration warnings + # - Carbon Footprint: CO2 per gram consumed estimate by material type + # - Bambu Lab Sync: pull current spool from AMS automatically (#217) + # - Hygroscopic Tracking: detailed moisture %, drying history, sensor data (#609) + # - OctoEverywhere Integration: sync with OctoPrint/Klipper printers (#788) + # - Custom Export Formats: Hueforge JSON, other slicer export plugins (#854) diff --git a/spoolman/main.py b/spoolman/main.py index c49c48ea0..aaa4520a5 100644 --- a/spoolman/main.py +++ b/spoolman/main.py @@ -17,6 +17,7 @@ from spoolman.api.v1.router import app as v1_app from spoolman.client import SinglePageApplication from spoolman.database import database +from spoolman.demo_applications import register_demo_applications from spoolman.prometheus.metrics import registry # Define a console logger @@ -170,6 +171,9 @@ async def startup() -> None: logger.info("Setting up database...") database.setup_db(database.get_connection_url()) + logger.info("Registering applications...") + register_demo_applications() + logger.info("Performing migrations...") # Run alembic in a subprocess. # There is some issue with the uvicorn worker that causes the process to hang when running alembic directly. diff --git a/spoolman/settings.py b/spoolman/settings.py index 8018e8b1a..209b7c4d4 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -68,9 +68,9 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([])) register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([])) -register_setting("complex_fields_vendor", SettingType.ARRAY, json.dumps([])) -register_setting("complex_fields_filament", SettingType.ARRAY, json.dumps([])) -register_setting("complex_fields_spool", SettingType.ARRAY, json.dumps([])) +register_setting("applications_vendor", SettingType.ARRAY, json.dumps([])) +register_setting("applications_filament", SettingType.ARRAY, json.dumps([])) +register_setting("applications_spool", SettingType.ARRAY, json.dumps([])) register_setting("base_url", SettingType.STRING, json.dumps("")) register_setting("locations", SettingType.ARRAY, json.dumps([])) From 52da7fa1071488994e411360ce500642e6040be6 Mon Sep 17 00:00:00 2001 From: akira69 Date: Thu, 19 Mar 2026 09:50:35 -0500 Subject: [PATCH 3/3] fix(applications): fix i18n nesting, standardize card heights, swap demo apps - Move 'applications' i18n keys from settings.applications to top-level - Add status.active/partial/inactive keys for badge text - Add display:flex to Col and Card for uniform card heights - Remove spool_count (already exists as a feature in separate PR) - Remove filament_textures (better implemented as a common extra field) - Add Filament Calibration Profiles app (flow rate, PA, temp tower per filament) - Add Bambu AMS Sync app (auto-sync spool data with AMS unit) --- client/public/locales/en/common.json | 51 ++++++----- client/src/components/applicationCard.tsx | 8 +- client/src/pages/applications/index.tsx | 2 +- spoolman/demo_applications.py | 101 ++++++++++++---------- 4 files changed, 88 insertions(+), 74 deletions(-) diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 656a74c56..870e9d5bc 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -354,30 +354,35 @@ "key_not_changed": "Please change the key to something else.", "delete_confirm": "Delete field {{name}}?", "delete_confirm_description": "This will delete the field and all associated data for all entities." + } + }, + "applications": { + "tab": "Applications", + "description": "

Applications add optional, pre-defined behaviors to your Spoolman instance.

Enabling an application can add specialized display, actions, calculated values, or list columns. Disabled applications remain hidden so the standard UI stays simpler.

", + "empty": "No applications are currently available.", + "all": "All", + "status": { + "active": "Active", + "partial": "Partial", + "inactive": "Inactive" }, - "applications": { - "tab": "Applications", - "description": "

Applications add optional, pre-defined behaviors to your Spoolman instance.

Enabling an application can add specialized display, actions, calculated values, or list columns. Disabled applications remain hidden so the standard UI stays simpler.

", - "empty": "No applications are currently available.", - "all": "All", - "columns": { - "name": "Name", - "description": "Feature", - "enable_description": "What Enabling Adds", - "surfaces": "Surfaces", - "enabled": "Enabled" - }, - "surfaces": { - "show": "Show", - "edit": "Edit", - "list": "List", - "action": "Action", - "derived": "Derived" - }, - "messages": { - "enabled": "Enabled {{name}}.", - "disabled": "Disabled {{name}}." - } + "columns": { + "name": "Name", + "description": "Feature", + "enable_description": "What Enabling Adds", + "surfaces": "Surfaces", + "enabled": "Enabled" + }, + "surfaces": { + "show": "Show", + "edit": "Edit", + "list": "List", + "action": "Action", + "derived": "Derived" + }, + "messages": { + "enabled": "Enabled {{name}}.", + "disabled": "Disabled {{name}}." } }, "documentTitle": { diff --git a/client/src/components/applicationCard.tsx b/client/src/components/applicationCard.tsx index ae2e04775..74526948a 100644 --- a/client/src/components/applicationCard.tsx +++ b/client/src/components/applicationCard.tsx @@ -28,10 +28,10 @@ export function ApplicationCard({ appKey, name, description, icon, states, onTog const badgeStatus = allEnabled ? "success" : anyEnabled ? "warning" : "default"; const badgeText = allEnabled - ? t("applications.columns.enabled") + ? t("applications.status.active") : anyEnabled - ? t("applications.columns.enabled") - : t("applications.all"); + ? t("applications.status.partial") + : t("applications.status.inactive"); const avatarContent = icon ?? name.charAt(0).toUpperCase(); @@ -42,7 +42,7 @@ export function ApplicationCard({ appKey, name, description, icon, states, onTog > navigate(`/applications/${appKey}`)}> Configure diff --git a/client/src/pages/applications/index.tsx b/client/src/pages/applications/index.tsx index 3eed17f43..7ad3126b2 100644 --- a/client/src/pages/applications/index.tsx +++ b/client/src/pages/applications/index.tsx @@ -103,7 +103,7 @@ export const Applications = () => { {filteredGroups.map(([appKey, states]) => { const first = states[0]; return ( -
+ None: """Register all demo/concept application stubs.""" register_application( ApplicationDefinition( - key="spool_count", - app_key="spool_count", - icon="🔢", - entity_type=EntityType.filament, - name="Spool Count", - description="Shows the number of spools associated with each filament directly in the filament list.", - enable_description="Adds a Spools column to the filament list showing total and available spool counts.", - surfaces=[ApplicationSurface.list, ApplicationSurface.derived], + key="drying_tracker", + app_key="drying_tracker", + icon="🌡️", + entity_type=EntityType.spool, + name="Drying Tracker", + description=( + "Record drying cycles for hygroscopic filaments. " + "Tracks temperature, duration, and the date each spool was last dried." + ), + enable_description=( + "Adds drying cycle history to spool detail pages and a drying status badge in the spool list." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.list], ) ) @@ -38,29 +43,11 @@ def register_demo_applications() -> None: "Warns when a spool's remaining weight drops below a configurable threshold, " "helping you reorder before running out." ), - enable_description="Adds alert badges to low-stock spool rows and a summary warning on the dashboard.", + enable_description=("Adds alert badges to low-stock spool rows and a summary warning on the dashboard."), surfaces=[ApplicationSurface.list, ApplicationSurface.action], ) ) - register_application( - ApplicationDefinition( - key="drying_tracker", - app_key="drying_tracker", - icon="🌡️", - entity_type=EntityType.spool, - name="Drying Tracker", - description=( - "Record drying cycles for hygroscopic filaments. " - "Tracks temperature, duration, and the date each spool was last dried." - ), - enable_description=( - "Adds drying cycle history to spool detail pages and a drying status badge in the spool list." - ), - surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.list], - ) - ) - register_application( ApplicationDefinition( key="print_history", @@ -89,7 +76,7 @@ def register_demo_applications() -> None: "Maintains a timestamped log of every weight change made to a spool, " "making it easy to track consumption over time." ), - enable_description="Adds a weight history tab to spool detail pages with a consumption graph.", + enable_description=("Adds a weight history tab to spool detail pages with a consumption graph."), surfaces=[ApplicationSurface.show, ApplicationSurface.list], ) ) @@ -105,43 +92,65 @@ def register_demo_applications() -> None: "Choose which spool and filament fields are encoded in the label QR code. " "Supports NFC tags and Home Assistant integrations." ), - enable_description="Adds a QR field configuration panel to spool settings and label printing options.", + enable_description=("Adds a QR field configuration panel to spool settings and label printing options."), surfaces=[ApplicationSurface.action], ) ) register_application( ApplicationDefinition( - key="filament_textures", - app_key="filament_textures", - icon="🎨", + key="storage_conditions", + app_key="storage_conditions", + icon="🏠", + entity_type=EntityType.spool, + name="Storage Conditions", + description=( + "Log where each spool is stored and optionally record ambient humidity " + "and temperature readings at the storage location." + ), + enable_description=("Adds storage location and conditions fields to spool detail and edit pages."), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit], + ) + ) + + register_application( + ApplicationDefinition( + key="filament_calibration", + app_key="filament_calibration", + icon="🎯", entity_type=EntityType.filament, - name="Filament Textures", + name="Filament Calibration Profiles", description=( - "Add texture classification to filaments: Matte, Silk, Glossy, Sparkle, Wood-fill, and others. " - "Makes it easy to filter and search by surface finish." + "Store per-filament calibration data: flow rate, pressure advance, " + "PA smooth time, and temperature tower results. " + "Eliminates manual re-tuning when switching between filaments." ), enable_description=( - "Adds a Texture field to filament create/edit forms " - "and enables texture-based filtering in the filament list." + "Adds a Calibration tab to filament detail pages and a calibration status badge in the filament list." ), - surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.list], + surfaces=[ + ApplicationSurface.show, + ApplicationSurface.edit, + ApplicationSurface.list, + ], ) ) register_application( ApplicationDefinition( - key="storage_conditions", - app_key="storage_conditions", - icon="🏠", + key="bambu_ams_sync", + app_key="bambu_ams_sync", + icon="🔄", entity_type=EntityType.spool, - name="Storage Conditions", + name="Bambu AMS Sync", description=( - "Log where each spool is stored and optionally record ambient humidity " - "and temperature readings at the storage location." + "Automatically sync spool data with a Bambu Lab AMS unit. " + "Pulls active spool, updates remaining weight, and maps AMS slot to Spoolman spool." ), - enable_description="Adds storage location and conditions fields to spool detail and edit pages.", - surfaces=[ApplicationSurface.show, ApplicationSurface.edit], + enable_description=( + "Adds AMS slot assignment to spool detail pages and auto-updates remaining weight after each print." + ), + surfaces=[ApplicationSurface.show, ApplicationSurface.edit, ApplicationSurface.action], ) )