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
1 change: 1 addition & 0 deletions client/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@
"filament": "Filaments",
"fields": {
"id": "ID",
"spool_count": "Spool Count",
"vendor_name": "Manufacturer",
"vendor": "Manufacturer",
"name": "Name",
Expand Down
12 changes: 8 additions & 4 deletions client/src/components/column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface FilteredColumnProps {
filters?: ColumnFilterItem[];
filteredValue?: string[];
allowMultipleFilters?: boolean;
includeEmptyFilter?: boolean;
onFilterDropdownOpen?: () => void;
loadingFilters?: boolean;
}
Expand Down Expand Up @@ -188,6 +189,7 @@ export function RichColumn<Obj extends Entity>(
interface FilteredQueryColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[], unknown>;
allowMultipleFilters?: boolean;
includeEmptyOption?: boolean;
}

export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColumnProps<Obj>) {
Expand All @@ -205,10 +207,12 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
return item;
});
}
filters.push({
text: "<empty>",
value: "<empty>",
});
if (props.includeEmptyOption ?? true) {
filters.push({
text: "<empty>",
value: "<empty>",
});
}

const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
Expand Down
49 changes: 48 additions & 1 deletion client/src/pages/filaments/list.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Button, Dropdown, Table } from "antd";
import { ColumnFilterItem } from "antd/es/table/interface";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useMemo, useState } from "react";
Expand All @@ -27,6 +29,7 @@ import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { useCurrencyFormatter } from "../../utils/settings";
import { getAPIURL } from "../../utils/url";
import { IFilament } from "./model";

dayjs.extend(utc);
Expand All @@ -50,10 +53,19 @@ function translateColumnI18nKey(columnName: string): string {
return `filament.fields.${columnName}`;
}

function getColumnLabel(t: (key: string, options?: unknown) => string, columnName: string): string {
if (columnName === "spool_count") {
return t("filament.fields.spool_count", { defaultValue: "Spool Count" });
}

return t(translateColumnI18nKey(columnName));
}

const namespace = "filamentList-v2";

const allColumns: (keyof IFilamentCollapsed & string)[] = [
"id",
"spool_count",
"vendor.name",
"name",
"material",
Expand All @@ -72,12 +84,35 @@ const defaultColumns = allColumns.filter(
(column_id) => ["registered", "density", "diameter", "spool_weight"].indexOf(column_id) === -1,
);

function useSpoolmanSpoolCounts(enabled: boolean = false) {
return useQuery<number[], unknown, ColumnFilterItem[]>({
enabled,
queryKey: ["filamentSpoolCounts"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/filament/spool-count");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data
.sort((a, b) => a - b)
.map((count) => ({
text: String(count),
value: String(count),
}));
},
});
}

export const FilamentList = () => {
const t = useTranslate();
const invalidate = useInvalidate();
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.filament);
const currencyFormatter = useCurrencyFormatter();
const querySpoolCounts = useSpoolmanSpoolCounts(true);

const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];

Expand Down Expand Up @@ -194,7 +229,7 @@ export const FilamentList = () => {

return {
key: column_id,
label: t(translateColumnI18nKey(column_id)),
label: getColumnLabel(t, column_id),
};
}),
selectedKeys: showColumns,
Expand Down Expand Up @@ -230,6 +265,18 @@ export const FilamentList = () => {
i18ncat: "filament",
width: 70,
}),
FilteredQueryColumn({
...commonProps,
id: "spool_count",
dataId: "spool_count",
title: t("filament.fields.spool_count"),
filterValueQuery: querySpoolCounts,
// Spool count is always a computed number, so an <empty> bucket would be
// misleading noise rather than a real "missing value" state.
includeEmptyOption: false,
width: 120,
transform: (value) => value ?? 0,
}),
FilteredQueryColumn({
...commonProps,
id: "vendor.name",
Expand Down
1 change: 1 addition & 0 deletions client/src/pages/filaments/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface IFilament {
diameter: number;
weight?: number;
spool_weight?: number;
spool_count?: number;
article_number?: string;
comment?: string;
settings_extruder_temp?: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""add index on spool.filament_id.

Revision ID: b5f9c2e31a1b
Revises: 415a8f855e14
Create Date: 2026-02-20 16:05:00.000000
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "b5f9c2e31a1b"
down_revision = "415a8f855e14"
branch_labels = None
depends_on = None


def upgrade() -> None:
"""Perform the upgrade."""
conn = op.get_bind()
inspector = sa.inspect(conn)
# Keep the migration idempotent so local PR containers can be rebuilt against the
# same SQLite file without failing on an index that already exists.
index_names = {index["name"] for index in inspector.get_indexes("spool")}
if "ix_spool_filament_id" not in index_names:
op.create_index("ix_spool_filament_id", "spool", ["filament_id"], unique=False)


def downgrade() -> None:
"""Perform the downgrade."""
conn = op.get_bind()
inspector = sa.inspect(conn)
index_names = {index["name"] for index in inspector.get_indexes("spool")}
if "ix_spool_filament_id" in index_names:
op.drop_index("ix_spool_filament_id", table_name="spool")
27 changes: 27 additions & 0 deletions spoolman/api/v1/filament.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,15 @@ async def find(
examples=["polymaker_pla_polysonicblack_1000_175"],
),
] = None,
spool_count: Annotated[
str | None,
Query(
title="Spool Count",
description="Match filaments with an exact spool count. Separate multiple counts with a comma.",
pattern=r"^\d+(,\d+)*$",
examples=["0", "1,2,3"],
),
] = None,
sort: Annotated[
str | None,
Query(
Expand Down Expand Up @@ -331,6 +340,10 @@ async def find(
vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")]
else:
vendor_ids = None
if spool_count is not None:
spool_counts = [int(spool_count_item) for spool_count_item in spool_count.split(",")]
else:
spool_counts = None

if color_hex is not None:
matched_filaments = await filament.find_by_color(
Expand All @@ -354,6 +367,7 @@ async def find(
sort_by=sort_by,
limit=limit,
offset=offset,
spool_count=spool_counts,
)

# Set x-total-count header for pagination
Expand All @@ -366,6 +380,19 @@ async def find(
)


@router.get(
"/spool-count",
name="Find filament spool counts",
description="Get distinct spool-count values across filaments.",
response_model_exclude_none=True,
)
async def find_spool_counts(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[int]:
return await filament.find_spool_counts(db=db)


@router.websocket(
"",
name="Listen to filament changes",
Expand Down
7 changes: 7 additions & 0 deletions spoolman/api/v1/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ class Filament(BaseModel):
examples=[1000],
)
spool_weight: float | None = Field(None, ge=0, description="The empty spool weight, in grams.", examples=[140])
spool_count: int | None = Field(
None,
ge=0,
description="Number of spools associated with this filament.",
examples=[3],
)
article_number: str | None = Field(
None,
max_length=64,
Expand Down Expand Up @@ -212,6 +218,7 @@ def from_db(item: models.Filament) -> "Filament":
diameter=item.diameter,
weight=item.weight,
spool_weight=item.spool_weight,
spool_count=getattr(item, "spool_count", None),
article_number=item.article_number,
comment=item.comment,
settings_extruder_temp=item.settings_extruder_temp,
Expand Down
Loading