Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions client/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,35 @@
"delete_confirm_description": "This will delete the field and all associated data for all entities."
}
},
"applications": {
"tab": "Applications",
"description": "<p>Applications add optional, pre-defined behaviors to your Spoolman instance.</p><p>Enabling an application can add specialized display, actions, calculated values, or list columns. Disabled applications remain hidden so the standard UI stays simpler.</p>",
"empty": "No applications are currently available.",
"all": "All",
"status": {
"active": "Active",
"partial": "Partial",
"inactive": "Inactive"
},
"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": {
"default": "Spoolman",
"suffix": " | Spoolman",
Expand All @@ -368,6 +397,9 @@
"locations": {
"list": "Locations | Spoolman"
},
"applications": {
"list": "Applications | Spoolman"
},
"filament": {
"list": "Filaments | Spoolman",
"show": "#{{id}} Show Filament | Spoolman",
Expand Down
11 changes: 11 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ErrorComponent } from "@refinedev/antd";
import "@refinedev/antd/dist/reset.css";

import {
AppstoreOutlined,
FileOutlined,
HighlightOutlined,
HomeOutlined,
Expand Down Expand Up @@ -150,6 +151,14 @@ function App() {
icon: <TableOutlined />,
},
},
{
name: "applications",
list: "/applications",
meta: {
canDelete: false,
icon: <AppstoreOutlined />,
},
},
{
name: "settings",
list: "/settings",
Expand Down Expand Up @@ -225,6 +234,8 @@ function App() {
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
<Route path="/help" element={<LoadablePage name="help" />} />
<Route path="/locations" element={<LoadablePage name="locations" />} />
<Route path="/applications" element={<LoadablePage name="applications" />} />
<Route path="/applications/:appKey" element={<LoadablePage name="applications/detail" />} />
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
Expand Down
87 changes: 87 additions & 0 deletions client/src/components/applicationCard.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
toggling: { entityType: EntityType; key: string } | null;
}

const ENTITY_COLORS: Record<EntityType, string> = {
[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.status.active")
: anyEnabled
? t("applications.status.partial")
: t("applications.status.inactive");

const avatarContent = icon ?? name.charAt(0).toUpperCase();

return (
<Badge.Ribbon
text={badgeText}
color={badgeStatus === "success" ? "green" : badgeStatus === "warning" ? "orange" : "gray"}
>
<Card
hoverable
style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column" }}
actions={[
<Button key="configure" type="link" onClick={() => navigate(`/applications/${appKey}`)}>
Configure
</Button>,
]}
>
<Card.Meta
avatar={
<Avatar size={48} style={{ fontSize: "1.5em", backgroundColor: "#f0f0f0", color: "#333" }}>
{avatarContent}
</Avatar>
}
title={<Typography.Text strong>{name}</Typography.Text>}
description={
<Typography.Text type="secondary" style={{ whiteSpace: "normal" }}>
{description}
</Typography.Text>
}
/>
<Space wrap style={{ marginTop: 12 }}>
{states.map((state) => (
<Tag
key={state.entity_type}
color={ENTITY_COLORS[state.entity_type]}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
{t(`${state.entity_type}.${state.entity_type}`)}
<Switch
size="small"
checked={state.enabled}
loading={toggling?.entityType === state.entity_type && toggling?.key === state.key}
onChange={(checked) => onToggle(state.entity_type, state.key, checked)}
onClick={(_, e) => e.stopPropagation()}
style={{ marginLeft: 4 }}
/>
</Tag>
))}
</Space>
</Card>
</Badge.Ribbon>
);
}
176 changes: 176 additions & 0 deletions client/src/pages/applications/detail/index.tsx
Original file line number Diff line number Diff line change
@@ -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, string> = {
[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<string | null>(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<ApplicationState>[] = [
{
title: t("applications.columns.name"),
dataIndex: "name",
key: "name",
width: "18%",
},
{
title: "Entity",
dataIndex: "entity_type",
key: "entity_type",
width: "12%",
render: (et: EntityType) => <Tag color={ENTITY_COLORS[et]}>{t(`${et}.${et}`)}</Tag>,
},
{
title: t("applications.columns.description"),
dataIndex: "description",
key: "description",
width: "24%",
render: (value: string) => (
<Typography.Text style={{ whiteSpace: "normal", wordBreak: "break-word" }}>{value}</Typography.Text>
),
},
{
title: t("applications.columns.enable_description"),
dataIndex: "enable_description",
key: "enable_description",
width: "28%",
render: (value: string) => (
<Typography.Text style={{ whiteSpace: "normal", wordBreak: "break-word" }}>{value}</Typography.Text>
),
},
{
title: t("applications.columns.surfaces"),
dataIndex: "surfaces",
key: "surfaces",
width: "10%",
render: (surfaces: ApplicationSurface[]) => (
<Space size={[4, 4]} wrap>
{surfaces.map((s) => (
<Tag key={s}>{t(`applications.surfaces.${s}`)}</Tag>
))}
</Space>
),
},
{
title: t("applications.columns.enabled"),
dataIndex: "enabled",
key: "enabled",
align: "center",
width: "8%",
render: (enabled: boolean, record) => (
<Switch
checked={enabled}
loading={pendingKey === `${record.entity_type}:${record.key}`}
onChange={(checked) => handleToggle(record, checked)}
/>
),
},
];

return (
<div>
{contextHolder}
<Button
type="text"
icon={<LeftOutlined />}
onClick={() => navigate("/applications")}
style={{ marginBottom: 16 }}
>
{t("applications.tab")}
</Button>

{first && (
<Row align="middle" gutter={16} style={{ marginBottom: 24 }}>
<Col>
<Avatar size={64} style={{ fontSize: "2em", backgroundColor: token.colorBgLayout, color: token.colorText }}>
{first.icon ?? first.name.charAt(0).toUpperCase()}
</Avatar>
</Col>
<Col>
<Typography.Title level={2} style={{ margin: 0, color: token.colorText }}>
{first.name}
</Typography.Title>
<Typography.Text type="secondary">{first.description}</Typography.Text>
</Col>
</Row>
)}

<Form component={false} disabled={pendingKey !== null}>
<Table
columns={columns}
dataSource={states}
loading={isLoading}
pagination={false}
rowKey={(r) => `${r.entity_type}:${r.key}`}
locale={{
emptyText: <Empty description={t("applications.empty")} image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
/>
</Form>
</div>
);
};

export default ApplicationDetail;
Loading