From 914420d409a9d0088186a7ba6836181eef732a9e Mon Sep 17 00:00:00 2001 From: akira69 Date: Mon, 23 Feb 2026 22:27:01 -0600 Subject: [PATCH 1/7] feat(search): add generalized filament/spool search and supporting indexes --- client/src/components/column.tsx | 8 +- client/src/components/otherModels.tsx | 19 +-- ...00-b76f1b4c3f5a_filament_search_indexes.py | 35 ++++++ ..._1710-f1a3d9c2c4e1_spool_search_indexes.py | 27 +++++ spoolman/api/v1/filament.py | 11 ++ spoolman/api/v1/other.py | 26 ++++ spoolman/api/v1/spool.py | 11 ++ spoolman/database/filament.py | 23 ++++ spoolman/database/spool.py | 13 ++ spoolman/database/utils.py | 26 ++++ spoolman/import_externaldb.py | 113 ++++++++++++++++++ 11 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py create mode 100644 migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py create mode 100644 spoolman/import_externaldb.py diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 059b607f0..0a985b1c7 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -46,6 +46,7 @@ interface BaseColumnProps { title?: string; align?: AlignType; sorter?: boolean; + ellipsis?: boolean; t: (key: string) => string; navigate: (link: string) => void; dataSource: Obj[]; @@ -62,6 +63,7 @@ interface FilteredColumnProps { allowMultipleFilters?: boolean; onFilterDropdownOpen?: () => void; loadingFilters?: boolean; + filterSearch?: boolean | ((input: string, record: ColumnFilterItem) => boolean); } interface CustomColumnProps { @@ -90,6 +92,7 @@ function Column( dataIndex: props.id, align: props.align, title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`), + ellipsis: props.ellipsis, filterMultiple: props.allowMultipleFilters ?? true, width: props.width ?? undefined, onCell: props.onCell ?? undefined, @@ -108,6 +111,7 @@ function Column( if (props.filters && props.filteredValue) { columnProps.filters = props.filters; columnProps.filteredValue = props.filteredValue; + columnProps.filterSearch = props.filterSearch ?? true; if (props.loadingFilters) { columnProps.filterDropdown = ; } @@ -356,7 +360,9 @@ export function SpoolIconColumn(props: SpoolIconColumnProps< )} - {value} + + {value} + ); }, diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 0adabd80c..69fae0e55 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -86,29 +86,18 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { } export function useSpoolmanFilamentNames(enabled: boolean = false) { - return useQuery({ + return useQuery({ enabled: enabled, - queryKey: ["filaments"], + queryKey: ["filamentNames"], queryFn: async () => { - const response = await fetch(getAPIURL() + "/filament"); + const response = await fetch(getAPIURL() + "/filament-name"); if (!response.ok) { throw new Error("Network response was not ok"); } return response.json(); }, select: (data) => { - // Concatenate vendor name and filament name - let names = data - .filter((filament) => { - return filament.name !== null && filament.name !== undefined && filament.name !== ""; - }) - .map((filament) => { - return filament.name ?? ""; - }) - .sort(); - // Remove duplicates - names = [...new Set(names)]; - return names; + return data.sort(); }, }); } diff --git a/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py new file mode 100644 index 000000000..0acb13c85 --- /dev/null +++ b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py @@ -0,0 +1,35 @@ +"""filament_search_indexes. + +Revision ID: b76f1b4c3f5a +Revises: 415a8f855e14 +Create Date: 2026-02-11 17:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b76f1b4c3f5a" +down_revision = "415a8f855e14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + op.create_index("ix_vendor_name", "vendor", ["name"], unique=False) + op.create_index("ix_filament_name", "filament", ["name"], unique=False) + op.create_index("ix_filament_material", "filament", ["material"], unique=False) + op.create_index("ix_filament_article_number", "filament", ["article_number"], unique=False) + op.create_index("ix_filament_external_id", "filament", ["external_id"], unique=False) + op.create_index("ix_filament_vendor_id", "filament", ["vendor_id"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + op.drop_index("ix_filament_vendor_id", table_name="filament") + op.drop_index("ix_filament_external_id", table_name="filament") + op.drop_index("ix_filament_article_number", table_name="filament") + op.drop_index("ix_filament_material", table_name="filament") + op.drop_index("ix_filament_name", table_name="filament") + op.drop_index("ix_vendor_name", table_name="vendor") diff --git a/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py new file mode 100644 index 000000000..0fe2d096b --- /dev/null +++ b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py @@ -0,0 +1,27 @@ +"""spool_search_indexes. + +Revision ID: f1a3d9c2c4e1 +Revises: b76f1b4c3f5a +Create Date: 2026-02-11 17:10:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f1a3d9c2c4e1" +down_revision = "b76f1b4c3f5a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + op.create_index("ix_spool_location", "spool", ["location"], unique=False) + op.create_index("ix_spool_lot_nr", "spool", ["lot_nr"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + op.drop_index("ix_spool_lot_nr", table_name="spool") + op.drop_index("ix_spool_location", table_name="spool") diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..1ef682cc4 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -241,6 +241,16 @@ async def find( examples=["1", "1,2"], ), ] = None, + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "General search across vendor name, filament name, material, article number, and external ID. " + "Separate multiple terms with a comma. Surround a term with quotes for an exact match." + ), + ), + ] = None, name: Annotated[ str | None, Query( @@ -347,6 +357,7 @@ async def find( ids=filter_by_ids, vendor_name=vendor_name if vendor_name is not None else vendor_name_old, vendor_id=vendor_ids, + search=search, name=name, material=material, article_number=article_number, diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py index 866aced93..a5a6f3e6c 100644 --- a/spoolman/api/v1/other.py +++ b/spoolman/api/v1/other.py @@ -73,6 +73,32 @@ async def find_article_numbers( return await filament.find_article_numbers(db=db) +@router.get( + "/filament-name", + name="Find filament names", + description="Get a list of all filament names.", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all filament names.", + "content": { + "application/json": { + "example": [ + "PLA Basic Black", + "PETG Orange", + ], + }, + }, + }, + }, +) +async def find_filament_names( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await filament.find_names(db=db) + + @router.get( "/lot-number", name="Find lot numbers", diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..7244eef25 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -225,6 +225,16 @@ async def find( pattern=r"^-?\d+(,-?\d+)*$", ), ] = None, + search: Annotated[ + str | None, + Query( + title="Search", + description=( + "General search across vendor name, filament name, material, location, and lot number. " + "Separate multiple terms with a comma. Surround a term with quotes for an exact match." + ), + ), + ] = None, location: Annotated[ str | None, Query( @@ -292,6 +302,7 @@ async def find( filament_material=filament_material if filament_material is not None else filament_material_old, vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old, vendor_id=filament_vendor_ids, + search=search, location=location, lot_nr=lot_nr, allow_archived=allow_archived, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index e2d742758..95410410a 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -16,6 +16,7 @@ SortOrder, add_where_clause_int_in, add_where_clause_int_opt, + add_where_clause_search, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -98,6 +99,7 @@ async def find( ids: list[int] | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, + search: str | None = None, name: str | None = None, material: str | None = None, article_number: str | None = None, @@ -122,6 +124,17 @@ async def find( stmt = add_where_clause_int_in(stmt, models.Filament.id, ids) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_search( + stmt, + [ + models.Vendor.name, + models.Filament.name, + models.Filament.material, + models.Filament.article_number, + models.Filament.external_id, + ], + search, + ) stmt = add_where_clause_str_opt(stmt, models.Filament.name, name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number) @@ -211,6 +224,16 @@ async def find_materials( return [row[0] for row in rows.all() if row[0] is not None] +async def find_names( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of filament names by searching for distinct values in the filament table.""" + stmt = select(models.Filament.name).distinct() + rows = await db.execute(stmt) + return sorted([row[0] for row in rows.all() if row[0] is not None and row[0] != ""]) + + async def find_article_numbers( *, db: AsyncSession, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..8b4ca6709 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -17,6 +17,7 @@ SortOrder, add_where_clause_int, add_where_clause_int_opt, + add_where_clause_search, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -119,6 +120,7 @@ async def find( # noqa: C901, PLR0912 filament_material: str | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, + search: str | None = None, location: str | None = None, lot_nr: str | None = None, allow_archived: bool = False, @@ -143,6 +145,17 @@ async def find( # noqa: C901, PLR0912 stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_search( + stmt, + [ + models.Vendor.name, + models.Filament.name, + models.Filament.material, + models.Spool.location, + models.Spool.lot_nr, + ], + search, + ) stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material) stmt = add_where_clause_str_opt(stmt, models.Spool.location, location) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 2d8776c00..c19f50aac 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -85,6 +85,32 @@ def add_where_clause_str( return stmt +def add_where_clause_search( + stmt: Select, + fields: Sequence[attributes.InstrumentedAttribute[str | None]], + value: str | None, +) -> Select: + """Add a where clause for a general search across multiple string fields.""" + if value is not None: + conditions = [] + for value_part in value.split(","): + value_part = value_part.strip() + if len(value_part) == 0: + continue + # Do exact match if value_part is surrounded by quotes + if value_part[0] == '"' and value_part[-1] == '"': + term = value_part[1:-1] + conditions.append(sqlalchemy.or_(*[field == term for field in fields])) + # Do prefix match for better index usage + else: + pattern = f"{value_part}%" + conditions.append(sqlalchemy.or_(*[field.ilike(pattern) for field in fields])) + + if conditions: + stmt = stmt.where(sqlalchemy.or_(*conditions)) + return stmt + + def add_where_clause_int( stmt: Select, field: attributes.InstrumentedAttribute[int], diff --git a/spoolman/import_externaldb.py b/spoolman/import_externaldb.py new file mode 100644 index 000000000..77eab6106 --- /dev/null +++ b/spoolman/import_externaldb.py @@ -0,0 +1,113 @@ +"""Import filaments from the external database into the local DB.""" + +from __future__ import annotations + +import logging +from datetime import datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman import externaldb +from spoolman.api.v1.models import MultiColorDirection +from spoolman.database import models + +logger = logging.getLogger(__name__) + + +def _normalize_hex(value: str | None) -> str | None: + if not value: + return None + return value[1:] if value.startswith("#") else value + + +async def import_external_filaments( + db: AsyncSession, + *, + only_if_empty: bool = True, +) -> int: + """Import external filaments into the local database. + + Returns the number of imported filaments. + """ + if only_if_empty: + existing_count = await db.scalar(select(func.count(models.Filament.id))) + if existing_count and existing_count > 0: + logger.info("Skipping external DB import because filaments already exist (%d).", existing_count) + return 0 + + logger.info("Fetching external filaments for import.") + filaments = await externaldb.fetch_external_filaments() + + existing_vendor_rows = await db.execute(select(models.Vendor)) + vendors_by_external_id = { + vendor.external_id: vendor for vendor in existing_vendor_rows.scalars().all() if vendor.external_id + } + + existing_filament_rows = await db.execute( + select(models.Filament.external_id).where(models.Filament.external_id.is_not(None)), + ) + existing_filament_ids = {row[0] for row in existing_filament_rows.all() if row[0]} + + now = datetime.utcnow().replace(microsecond=0) + imported = 0 + + for filament in filaments: + if filament.id in existing_filament_ids: + continue + + manufacturer = filament.manufacturer.strip() + vendor_item = vendors_by_external_id.get(manufacturer) + if vendor_item is None: + vendor_item = models.Vendor( + name=manufacturer, + registered=now, + comment=None, + empty_spool_weight=None, + external_id=manufacturer, + extra=[], + ) + db.add(vendor_item) + await db.flush() + vendors_by_external_id[manufacturer] = vendor_item + + color_hex = _normalize_hex(filament.color_hex) + multi_color_hexes = None + if filament.color_hexes: + normalized = [_normalize_hex(value) for value in filament.color_hexes] + multi_color_hexes = ",".join([value for value in normalized if value]) + + multi_color_direction = None + if filament.multi_color_direction is not None: + multi_color_direction = MultiColorDirection(filament.multi_color_direction.value) + + spool_weight = filament.spool_weight + if spool_weight is None and vendor_item.empty_spool_weight is not None: + spool_weight = vendor_item.empty_spool_weight + + db_item = models.Filament( + registered=now, + name=filament.name, + vendor=vendor_item, + material=filament.material, + price=None, + density=filament.density, + diameter=filament.diameter, + weight=filament.weight, + spool_weight=spool_weight, + article_number=None, + comment=None, + settings_extruder_temp=filament.extruder_temp, + settings_bed_temp=filament.bed_temp, + color_hex=color_hex if filament.color_hex else None, + multi_color_hexes=None if filament.color_hex else multi_color_hexes, + multi_color_direction=multi_color_direction.value if multi_color_direction else None, + external_id=filament.id, + extra=[], + ) + db.add(db_item) + imported += 1 + + await db.commit() + logger.info("Imported %d external filaments.", imported) + return imported From 6401376b3a5ceeb2566e55ee3d5c37b888aa4ac0 Mon Sep 17 00:00:00 2001 From: akira69 Date: Mon, 23 Feb 2026 23:58:21 -0600 Subject: [PATCH 2/7] feat(ui/filters): add searchable filter dropdown with select-all and clear --- client/public/locales/en/common.json | 5 +- client/src/components/column.tsx | 439 +++++++++++++++++++++++++- client/src/components/otherModels.tsx | 59 +--- 3 files changed, 439 insertions(+), 64 deletions(-) diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..4688e3a86 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -29,7 +29,9 @@ "showArchived": "Show Archived", "notAccessTitle": "You don't have permission to access", "hideColumns": "Hide Columns", - "clearFilters": "Clear Filters" + "clearFilters": "Clear Filters", + "selectAll": "Select All", + "selectNone": "Select None" }, "warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.", "notifications": { @@ -226,6 +228,7 @@ "diameter": "Diameter", "weight": "Weight", "spool_weight": "Spool Weight", + "spool_count": "Spool Count", "article_number": "Article Number", "registered": "Registered", "comment": "Comment", diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 0a985b1c7..564a6bbb6 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -1,10 +1,11 @@ import { DateField, TextField } from "@refinedev/antd"; import { UseQueryResult } from "@tanstack/react-query"; -import { Button, Col, Dropdown, Row, Space, Spin } from "antd"; +import { Button, Checkbox, Col, Dropdown, Input, Row, Space, Spin } from "antd"; import { ColumnFilterItem, ColumnType } from "antd/es/table/interface"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { AlignType } from "rc-table/lib/interface"; +import { Key, useMemo, useState } from "react"; import { Link } from "react-router"; import { getFiltersForField, typeFilters } from "../utils/filtering"; import { enrichText } from "../utils/parsing"; @@ -16,6 +17,10 @@ import SpoolIcon from "./spoolIcon"; dayjs.extend(utc); +const FILTER_DROPDOWN_LIST_HEIGHT = 220; +const FILTER_DROPDOWN_ROW_HEIGHT = 28; +const FILTER_DROPDOWN_OVERSCAN = 6; + const FilterDropdownLoading = () => { return ( @@ -27,6 +32,307 @@ const FilterDropdownLoading = () => { ); }; +function filterSearchTerm(item: ColumnFilterItem): string { + const extraSearchTerm = (item as ColumnFilterItem & { sortId?: string }).sortId; + if (extraSearchTerm) { + return extraSearchTerm.toLowerCase(); + } + if (typeof item.text === "string") { + return item.text.toLowerCase(); + } + if (item.value !== undefined && item.value !== null) { + return String(item.value).toLowerCase(); + } + return ""; +} + +function valueKey(value: Key): string { + return String(value); +} + +function normalizeSearchableValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (Array.isArray(value)) { + return value.map((entry) => String(entry)).join(", "); + } + return String(value); +} + +function getRecordValue(record: unknown, dataIndex: string | string[]): unknown { + if (Array.isArray(dataIndex)) { + return dataIndex.reduce((current, part) => { + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + return (current as Record)[part]; + }, record); + } + + if (record !== null && record !== undefined && typeof record === "object") { + const recordObject = record as Record; + if (Object.prototype.hasOwnProperty.call(recordObject, dataIndex)) { + return recordObject[dataIndex]; + } + } + + return dataIndex.split(".").reduce((current, part) => { + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + return (current as Record)[part]; + }, record); +} + +function FilterDropdownContent(props: { + items: ColumnFilterItem[]; + selectedKeys: Key[]; + setSelectedKeys: (keys: Key[]) => void; + confirm: () => void; + clearFilters?: () => void; + allowMultipleFilters: boolean; + t: (key: string) => string; +}) { + const { items, selectedKeys, setSelectedKeys, confirm, clearFilters, allowMultipleFilters, t } = props; + const [searchQuery, setSearchQuery] = useState(""); + const [scrollTop, setScrollTop] = useState(0); + + const indexedItems = useMemo( + () => items.map((item) => ({ item, searchTerm: filterSearchTerm(item) })), + [items], + ); + + const filteredItems = useMemo(() => { + const search = searchQuery.trim().toLowerCase(); + if (search.length === 0) { + return items; + } + return indexedItems.filter(({ searchTerm }) => searchTerm.includes(search)).map(({ item }) => item); + }, [indexedItems, items, searchQuery]); + + const filteredValues = useMemo( + () => + filteredItems + .map((item) => item.value) + .filter((value): value is Key => value !== undefined && value !== null && typeof value !== "boolean"), + [filteredItems], + ); + + const selectedKeySet = useMemo(() => new Set(selectedKeys.map(valueKey)), [selectedKeys]); + const filteredValueKeySet = useMemo(() => new Set(filteredValues.map(valueKey)), [filteredValues]); + const dropdownWidth = useMemo(() => { + const minWidth = 240; + const maxWidth = minWidth * 2; + // Keep a stable width while typing/filtering by sizing from the full list. + const longestTextLength = indexedItems.reduce((maxLength, indexedItem) => { + return Math.max(maxLength, indexedItem.searchTerm.length); + }, 0); + const estimatedWidth = 90 + Math.min(longestTextLength, 48) * 8; + const buttonLabelWidth = Math.max( + (t("buttons.selectAll").length + t("buttons.selectNone").length + 8) * 7, + minWidth, + ); + return Math.min(Math.max(minWidth, estimatedWidth, buttonLabelWidth), maxWidth); + }, [indexedItems, t]); + + const visibleCount = Math.max(1, Math.ceil(FILTER_DROPDOWN_LIST_HEIGHT / FILTER_DROPDOWN_ROW_HEIGHT)); + const startIndex = Math.max(0, Math.floor(scrollTop / FILTER_DROPDOWN_ROW_HEIGHT) - FILTER_DROPDOWN_OVERSCAN); + const endIndex = Math.min(filteredItems.length, startIndex + visibleCount + FILTER_DROPDOWN_OVERSCAN * 2); + const visibleItems = filteredItems.slice(startIndex, endIndex); + + const selectAllFiltered = () => { + if (filteredValues.length === 0) { + return; + } + if (!allowMultipleFilters) { + setSelectedKeys([filteredValues[0]]); + return; + } + + const existing = new Map(selectedKeys.map((value) => [valueKey(value), value])); + filteredValues.forEach((value) => existing.set(valueKey(value), value)); + setSelectedKeys(Array.from(existing.values())); + }; + + const selectNoneFiltered = () => { + if (!allowMultipleFilters) { + const firstNonFiltered = selectedKeys.find((value) => !filteredValueKeySet.has(valueKey(value))); + setSelectedKeys(firstNonFiltered ? [firstNonFiltered] : []); + return; + } + setSelectedKeys(selectedKeys.filter((value) => !filteredValueKeySet.has(valueKey(value)))); + }; + + return ( +
+ { + setSearchQuery(event.target.value); + setScrollTop(0); + }} + /> +
+ + +
+
setScrollTop(event.currentTarget.scrollTop)} + > +
+ {visibleItems.map((item, offset) => { + const index = startIndex + offset; + const optionValue = item.value; + if (optionValue === undefined || optionValue === null || typeof optionValue === "boolean") { + return null; + } + const checked = selectedKeySet.has(valueKey(optionValue)); + return ( +
+ { + const isChecked = event.target.checked; + if (!allowMultipleFilters) { + setSelectedKeys(isChecked ? [optionValue] : []); + return; + } + + if (isChecked) { + setSelectedKeys([...selectedKeys, optionValue]); + } else { + setSelectedKeys(selectedKeys.filter((value) => valueKey(value) !== valueKey(optionValue))); + } + }} + > + + {item.text} + + +
+ ); + })} +
+
+ + + + +
+ ); +} + + +function SearchFilterDropdownContent(props: { + selectedKeys: Key[]; + setSelectedKeys: (keys: Key[]) => void; + confirm: () => void; + clearFilters?: () => void; + t: (key: string) => string; + placeholder: string; +}) { + const { selectedKeys, setSelectedKeys, confirm, clearFilters, t, placeholder } = props; + const currentValue = selectedKeys.length > 0 ? String(selectedKeys[0]) : ""; + + return ( +
+ { + const value = event.target.value; + setSelectedKeys(value ? [value] : []); + }} + onPressEnter={() => confirm()} + /> + + + + +
+ ); +} + interface Entity { id: number; } @@ -46,7 +352,9 @@ interface BaseColumnProps { title?: string; align?: AlignType; sorter?: boolean; - ellipsis?: boolean; + searchable?: boolean; + searchPlaceholder?: string; + searchValueFormatter?: (rawValue: unknown, record: Obj) => string; t: (key: string) => string; navigate: (link: string) => void; dataSource: Obj[]; @@ -55,6 +363,7 @@ interface BaseColumnProps { actions?: (record: Obj) => Action[]; transform?: (value: unknown) => unknown; render?: (rawValue: string | undefined, record: Obj) => React.ReactNode; + ellipsis?: boolean; } interface FilteredColumnProps { @@ -63,7 +372,6 @@ interface FilteredColumnProps { allowMultipleFilters?: boolean; onFilterDropdownOpen?: () => void; loadingFilters?: boolean; - filterSearch?: boolean | ((input: string, record: ColumnFilterItem) => boolean); } interface CustomColumnProps { @@ -92,10 +400,10 @@ function Column( dataIndex: props.id, align: props.align, title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`), - ellipsis: props.ellipsis, filterMultiple: props.allowMultipleFilters ?? true, width: props.width ?? undefined, onCell: props.onCell ?? undefined, + ellipsis: props.ellipsis ?? false, }; // Sorting @@ -111,10 +419,22 @@ function Column( if (props.filters && props.filteredValue) { columnProps.filters = props.filters; columnProps.filteredValue = props.filteredValue; - columnProps.filterSearch = props.filterSearch ?? true; - if (props.loadingFilters) { - columnProps.filterDropdown = ; - } + columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => { + if (props.loadingFilters) { + return ; + } + return ( + + ); + }; columnProps.filterDropdownProps = { onOpenChange: (open) => { if (open && props.onFilterDropdownOpen) { @@ -125,6 +445,69 @@ function Column( if (props.dataId) { columnProps.key = props.dataId; } + } else if (props.searchable) { + const filterField = props.dataId ?? (Array.isArray(props.id) ? undefined : (props.id as keyof Obj)); + if (filterField) { + const typedFilters = typeFilters(props.tableState.filters); + const filteredValue = getFiltersForField(typedFilters, filterField); + const searchableValues = new Map(); + const searchValueDataIndex = props.dataId ?? props.id; + + props.dataSource.forEach((record) => { + const rawValue = getRecordValue(record, searchValueDataIndex); + const displayValue = props.searchValueFormatter + ? props.searchValueFormatter(rawValue, record) + : normalizeSearchableValue(rawValue); + const normalizedDisplayValue = displayValue ?? ""; + const filterValue = normalizedDisplayValue === "" ? "" : normalizedDisplayValue; + if (!searchableValues.has(filterValue)) { + searchableValues.set(filterValue, normalizedDisplayValue); + } + }); + + const searchableFilters: ColumnFilterItem[] = Array.from(searchableValues.entries()) + .map(([value, label]) => ({ value, text: label })) + .sort((left, right) => + filterSearchTerm(left).localeCompare(filterSearchTerm(right), undefined, { + numeric: true, + sensitivity: "base", + }), + ); + + columnProps.filteredValue = filteredValue; + + if (searchableFilters.length > 0) { + columnProps.filters = searchableFilters; + columnProps.filterMultiple = true; + columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => ( + + ); + } else { + columnProps.filterMultiple = false; + columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => ( + + ); + } + + if (props.dataId) { + columnProps.key = props.dataId; + } + } } // Render @@ -174,6 +557,7 @@ export function SortedColumn(props: BaseColumnProps) { return Column({ ...props, sorter: true, + searchable: props.searchable ?? true, }); } @@ -182,6 +566,7 @@ export function RichColumn( ) { return Column({ ...props, + searchable: props.searchable ?? true, render: (rawValue: string | undefined) => { const value = props.transform ? props.transform(rawValue) : rawValue; return enrichText(value); @@ -192,6 +577,8 @@ export function RichColumn( interface FilteredQueryColumnProps extends BaseColumnProps { filterValueQuery: UseQueryResult; allowMultipleFilters?: boolean; + includeEmptyFilter?: boolean; + emptyFilterLabel?: string; } export function FilteredQueryColumn(props: FilteredQueryColumnProps) { @@ -209,19 +596,29 @@ export function FilteredQueryColumn(props: FilteredQueryColu return item; }); } - filters.push({ - text: "", - value: "", - }); + if (props.includeEmptyFilter !== false) { + filters.push({ + text: props.emptyFilterLabel ?? "", + value: "", + }); + } const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); const onFilterDropdownOpen = () => { - query.refetch(); + if (query.data === undefined && !query.isFetching) { + query.refetch(); + } }; - return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading }); + return Column({ + ...props, + filters, + filteredValue, + onFilterDropdownOpen, + loadingFilters: query.isLoading && query.data === undefined, + }); } interface NumberColumnProps extends BaseColumnProps { @@ -235,6 +632,7 @@ export function NumberColumn(props: NumberColumnProps) return Column({ ...props, align: "right", + searchable: props.searchable ?? true, render: (rawValue) => { const value = props.transform ? props.transform(rawValue) : rawValue; if (value === null || value === undefined) { @@ -257,6 +655,14 @@ export function NumberColumn(props: NumberColumnProps) export function DateColumn(props: BaseColumnProps) { return Column({ ...props, + searchable: props.searchable ?? true, + searchValueFormatter: (rawValue) => { + const value = props.transform ? props.transform(rawValue) : rawValue; + if (!value) { + return ""; + } + return dayjs.utc(value as string).local().format("YYYY-MM-DD HH:mm"); + }, render: (rawValue) => { const value = props.transform ? props.transform(rawValue) : rawValue; return ( @@ -360,9 +766,7 @@ export function SpoolIconColumn(props: SpoolIconColumnProps< )} - - {value} - + {value}
); }, @@ -372,6 +776,7 @@ export function SpoolIconColumn(props: SpoolIconColumnProps< export function NumberRangeColumn(props: NumberColumnProps) { return Column({ ...props, + searchable: props.searchable ?? true, render: (rawValue) => { const value = props.transform ? props.transform(rawValue) : rawValue; if (value === null || value === undefined) { diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 69fae0e55..81299a48c 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -1,5 +1,4 @@ import { useQuery } from "@tanstack/react-query"; -import { Tooltip } from "antd"; import { ColumnFilterItem } from "antd/es/table/interface"; import { IFilament } from "../pages/filaments/model"; import { IVendor } from "../pages/vendors/model"; @@ -25,55 +24,23 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { }) // Transform to ColumnFilterItem .map((filament) => { - let name = ""; - if (filament.vendor?.name) { - name = `${filament.vendor.name} - ${filament.name ?? ""}`; - } else { - name = `${filament.name ?? ""}`; - } + const name = filament.vendor?.name + ? `${filament.vendor.name} - ${filament.name ?? ""}` + : `${filament.name ?? ""}`; - const tooltipParts: React.ReactNode[] = []; - if (filament.color_hex) { - tooltipParts.push( -
, - ); - } - if (filament.material) { - tooltipParts.push(
{filament.material}
); - } - if (filament.weight) { - tooltipParts.push(
{filament.weight}g
); - } + const searchTerms = [ + name, + filament.material ?? "", + filament.color_hex ? `#${filament.color_hex}` : "", + filament.weight ? `${filament.weight}g` : "", + ] + .filter((term) => term !== "") + .join(" "); return { - text: ( - - {tooltipParts} - - } - > - {name} - - ), + text: name, value: filament.id, - sortId: name, + sortId: searchTerms, }; }) // Remove duplicates From c534532b61e2b12ab732ffb608072f167fb7671f Mon Sep 17 00:00:00 2001 From: akira69 Date: Sat, 28 Feb 2026 09:25:33 -0600 Subject: [PATCH 3/7] fix(filters): harden searchable filters and index migrations --- client/src/components/column.tsx | 2 +- client/src/utils/filtering.ts | 6 +-- ...00-b76f1b4c3f5a_filament_search_indexes.py | 46 ++++++++++++++----- ..._1710-f1a3d9c2c4e1_spool_search_indexes.py | 20 ++++++-- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 564a6bbb6..38b2ce5cf 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -446,7 +446,7 @@ function Column( columnProps.key = props.dataId; } } else if (props.searchable) { - const filterField = props.dataId ?? (Array.isArray(props.id) ? undefined : (props.id as keyof Obj)); + const filterField = props.dataId ?? (Array.isArray(props.id) || typeof props.id !== "string" ? undefined : props.id); if (filterField) { const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, filterField); diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts index da99b43c4..096f47609 100644 --- a/client/src/utils/filtering.ts +++ b/client/src/utils/filtering.ts @@ -1,7 +1,7 @@ import { CrudFilter, CrudOperators } from "@refinedev/core"; interface TypedCrudFilter { - field: keyof Obj; + field: keyof Obj | string; operator: Exclude; value: string[]; } @@ -16,9 +16,9 @@ export function typeFilters(filters: CrudFilter[]): TypedCrudFilter[] * @param field The field to get the filter values for. * @returns An array of filter values for the given field. */ -export function getFiltersForField( +export function getFiltersForField( filters: TypedCrudFilter[], - field: Field, + field: keyof Obj | string, ): string[] { const filterValues: string[] = []; filters.forEach((filter) => { diff --git a/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py index 0acb13c85..f2d6aec03 100644 --- a/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py +++ b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py @@ -17,19 +17,41 @@ def upgrade() -> None: """Perform the upgrade.""" - op.create_index("ix_vendor_name", "vendor", ["name"], unique=False) - op.create_index("ix_filament_name", "filament", ["name"], unique=False) - op.create_index("ix_filament_material", "filament", ["material"], unique=False) - op.create_index("ix_filament_article_number", "filament", ["article_number"], unique=False) - op.create_index("ix_filament_external_id", "filament", ["external_id"], unique=False) - op.create_index("ix_filament_vendor_id", "filament", ["vendor_id"], unique=False) + conn = op.get_bind() + inspector = sa.inspect(conn) + vendor_indexes = {index["name"] for index in inspector.get_indexes("vendor")} + filament_indexes = {index["name"] for index in inspector.get_indexes("filament")} + + if "ix_vendor_name" not in vendor_indexes: + op.create_index("ix_vendor_name", "vendor", ["name"], unique=False) + if "ix_filament_name" not in filament_indexes: + op.create_index("ix_filament_name", "filament", ["name"], unique=False) + if "ix_filament_material" not in filament_indexes: + op.create_index("ix_filament_material", "filament", ["material"], unique=False) + if "ix_filament_article_number" not in filament_indexes: + op.create_index("ix_filament_article_number", "filament", ["article_number"], unique=False) + if "ix_filament_external_id" not in filament_indexes: + op.create_index("ix_filament_external_id", "filament", ["external_id"], unique=False) + if "ix_filament_vendor_id" not in filament_indexes: + op.create_index("ix_filament_vendor_id", "filament", ["vendor_id"], unique=False) def downgrade() -> None: """Perform the downgrade.""" - op.drop_index("ix_filament_vendor_id", table_name="filament") - op.drop_index("ix_filament_external_id", table_name="filament") - op.drop_index("ix_filament_article_number", table_name="filament") - op.drop_index("ix_filament_material", table_name="filament") - op.drop_index("ix_filament_name", table_name="filament") - op.drop_index("ix_vendor_name", table_name="vendor") + conn = op.get_bind() + inspector = sa.inspect(conn) + vendor_indexes = {index["name"] for index in inspector.get_indexes("vendor")} + filament_indexes = {index["name"] for index in inspector.get_indexes("filament")} + + if "ix_filament_vendor_id" in filament_indexes: + op.drop_index("ix_filament_vendor_id", table_name="filament") + if "ix_filament_external_id" in filament_indexes: + op.drop_index("ix_filament_external_id", table_name="filament") + if "ix_filament_article_number" in filament_indexes: + op.drop_index("ix_filament_article_number", table_name="filament") + if "ix_filament_material" in filament_indexes: + op.drop_index("ix_filament_material", table_name="filament") + if "ix_filament_name" in filament_indexes: + op.drop_index("ix_filament_name", table_name="filament") + if "ix_vendor_name" in vendor_indexes: + op.drop_index("ix_vendor_name", table_name="vendor") diff --git a/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py index 0fe2d096b..45a6eae0d 100644 --- a/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py +++ b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py @@ -17,11 +17,23 @@ def upgrade() -> None: """Perform the upgrade.""" - op.create_index("ix_spool_location", "spool", ["location"], unique=False) - op.create_index("ix_spool_lot_nr", "spool", ["lot_nr"], unique=False) + conn = op.get_bind() + inspector = sa.inspect(conn) + spool_indexes = {index["name"] for index in inspector.get_indexes("spool")} + + if "ix_spool_location" not in spool_indexes: + op.create_index("ix_spool_location", "spool", ["location"], unique=False) + if "ix_spool_lot_nr" not in spool_indexes: + op.create_index("ix_spool_lot_nr", "spool", ["lot_nr"], unique=False) def downgrade() -> None: """Perform the downgrade.""" - op.drop_index("ix_spool_lot_nr", table_name="spool") - op.drop_index("ix_spool_location", table_name="spool") + conn = op.get_bind() + inspector = sa.inspect(conn) + spool_indexes = {index["name"] for index in inspector.get_indexes("spool")} + + if "ix_spool_lot_nr" in spool_indexes: + op.drop_index("ix_spool_lot_nr", table_name="spool") + if "ix_spool_location" in spool_indexes: + op.drop_index("ix_spool_location", table_name="spool") From 84faebd96195ddd5eb537748e6d1ada2545a37d0 Mon Sep 17 00:00:00 2001 From: akira69 Date: Tue, 3 Mar 2026 10:53:06 -0600 Subject: [PATCH 4/7] docs(filters): clarify local search and index guard intent --- client/src/components/column.tsx | 2 ++ .../2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py | 2 ++ .../2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py | 2 ++ spoolman/database/utils.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 38b2ce5cf..cbfeaa440 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -453,6 +453,8 @@ function Column( const searchableValues = new Map(); const searchValueDataIndex = props.dataId ?? props.id; + // Searchable dropdown values are built from the loaded rows so the filter stays + // instant and works even when the backend only supports normal field filters. props.dataSource.forEach((record) => { const rawValue = getRecordValue(record, searchValueDataIndex); const displayValue = props.searchValueFormatter diff --git a/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py index f2d6aec03..3d41584e8 100644 --- a/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py +++ b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py @@ -19,6 +19,8 @@ def upgrade() -> None: """Perform the upgrade.""" conn = op.get_bind() inspector = sa.inspect(conn) + # These guards let local PR/test databases be reused safely even when a branch + # is rebuilt against a file that already picked up some of the same indexes. vendor_indexes = {index["name"] for index in inspector.get_indexes("vendor")} filament_indexes = {index["name"] for index in inspector.get_indexes("filament")} diff --git a/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py index 45a6eae0d..6e5047ebc 100644 --- a/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py +++ b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py @@ -19,6 +19,8 @@ def upgrade() -> None: """Perform the upgrade.""" conn = op.get_bind() inspector = sa.inspect(conn) + # Match the filament-index migration's idempotent behavior so rebuilding a PR + # against an already-used SQLite file does not fail on duplicate indexes. spool_indexes = {index["name"] for index in inspector.get_indexes("spool")} if "ix_spool_location" not in spool_indexes: diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index c19f50aac..1389b837a 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -103,6 +103,8 @@ def add_where_clause_search( conditions.append(sqlalchemy.or_(*[field == term for field in fields])) # Do prefix match for better index usage else: + # Keep the general search index-friendly so the selector/search UX can + # scale on larger datasets without forcing full substring scans. pattern = f"{value_part}%" conditions.append(sqlalchemy.or_(*[field.ilike(pattern) for field in fields])) From fa55243f4bc75e45488c7d03a664f19c6eac8230 Mon Sep 17 00:00:00 2001 From: akira69 Date: Mon, 16 Mar 2026 16:58:22 -0500 Subject: [PATCH 5/7] style: Fix Prettier, ESLint, and Ruff violations per compliance standard --- client/src/components/column.tsx | 112 ++++++++++++++++--------------- client/src/utils/filtering.ts | 7 +- spoolman/database/utils.py | 12 ++-- spoolman/import_externaldb.py | 9 ++- 4 files changed, 75 insertions(+), 65 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index cbfeaa440..c02740e65 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -34,6 +34,7 @@ const FilterDropdownLoading = () => { function filterSearchTerm(item: ColumnFilterItem): string { const extraSearchTerm = (item as ColumnFilterItem & { sortId?: string }).sortId; + // Query-backed filters can attach a richer search key than the visible label alone. if (extraSearchTerm) { return extraSearchTerm.toLowerCase(); } @@ -61,6 +62,7 @@ function normalizeSearchableValue(value: unknown): string { } function getRecordValue(record: unknown, dataIndex: string | string[]): unknown { + // Table columns use both AntD array paths and dotted field ids from saved table state. if (Array.isArray(dataIndex)) { return dataIndex.reduce((current, part) => { if (current === null || current === undefined || typeof current !== "object") { @@ -94,14 +96,12 @@ function FilterDropdownContent(props: { allowMultipleFilters: boolean; t: (key: string) => string; }) { + // Keep multi-select filter dropdowns responsive even when the backend returns large option sets. const { items, selectedKeys, setSelectedKeys, confirm, clearFilters, allowMultipleFilters, t } = props; const [searchQuery, setSearchQuery] = useState(""); const [scrollTop, setScrollTop] = useState(0); - const indexedItems = useMemo( - () => items.map((item) => ({ item, searchTerm: filterSearchTerm(item) })), - [items], - ); + const indexedItems = useMemo(() => items.map((item) => ({ item, searchTerm: filterSearchTerm(item) })), [items]); const filteredItems = useMemo(() => { const search = searchQuery.trim().toLowerCase(); @@ -136,6 +136,7 @@ function FilterDropdownContent(props: { return Math.min(Math.max(minWidth, estimatedWidth, buttonLabelWidth), maxWidth); }, [indexedItems, t]); + // Virtualize the checkbox list so searching/selecting stays snappy for long filter lists. const visibleCount = Math.max(1, Math.ceil(FILTER_DROPDOWN_LIST_HEIGHT / FILTER_DROPDOWN_ROW_HEIGHT)); const startIndex = Math.max(0, Math.floor(scrollTop / FILTER_DROPDOWN_ROW_HEIGHT) - FILTER_DROPDOWN_OVERSCAN); const endIndex = Math.min(filteredItems.length, startIndex + visibleCount + FILTER_DROPDOWN_OVERSCAN * 2); @@ -205,56 +206,56 @@ function FilterDropdownContent(props: { > {visibleItems.map((item, offset) => { const index = startIndex + offset; - const optionValue = item.value; - if (optionValue === undefined || optionValue === null || typeof optionValue === "boolean") { - return null; - } - const checked = selectedKeySet.has(valueKey(optionValue)); - return ( -
- { - const isChecked = event.target.checked; - if (!allowMultipleFilters) { - setSelectedKeys(isChecked ? [optionValue] : []); - return; - } - - if (isChecked) { - setSelectedKeys([...selectedKeys, optionValue]); - } else { - setSelectedKeys(selectedKeys.filter((value) => valueKey(value) !== valueKey(optionValue))); - } + const optionValue = item.value; + if (optionValue === undefined || optionValue === null || typeof optionValue === "boolean") { + return null; + } + const checked = selectedKeySet.has(valueKey(optionValue)); + return ( +
- { + const isChecked = event.target.checked; + if (!allowMultipleFilters) { + setSelectedKeys(isChecked ? [optionValue] : []); + return; + } + + if (isChecked) { + setSelectedKeys([...selectedKeys, optionValue]); + } else { + setSelectedKeys(selectedKeys.filter((value) => valueKey(value) !== valueKey(optionValue))); + } }} > - {item.text} - - -
- ); + + {item.text} + +
+
+ ); })} @@ -283,7 +284,6 @@ function FilterDropdownContent(props: { ); } - function SearchFilterDropdownContent(props: { selectedKeys: Key[]; setSelectedKeys: (keys: Key[]) => void; @@ -292,6 +292,7 @@ function SearchFilterDropdownContent(props: { t: (key: string) => string; placeholder: string; }) { + // Fall back to raw text entry when the table cannot precompute a finite option list. const { selectedKeys, setSelectedKeys, confirm, clearFilters, t, placeholder } = props; const currentValue = selectedKeys.length > 0 ? String(selectedKeys[0]) : ""; @@ -446,7 +447,8 @@ function Column( columnProps.key = props.dataId; } } else if (props.searchable) { - const filterField = props.dataId ?? (Array.isArray(props.id) || typeof props.id !== "string" ? undefined : props.id); + const filterField = + props.dataId ?? (Array.isArray(props.id) || typeof props.id !== "string" ? undefined : props.id); if (filterField) { const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, filterField); @@ -609,6 +611,7 @@ export function FilteredQueryColumn(props: FilteredQueryColu const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); const onFilterDropdownOpen = () => { + // Defer distinct-value fetches until the user opens the dropdown to avoid eager list-page traffic. if (query.data === undefined && !query.isFetching) { query.refetch(); } @@ -663,7 +666,10 @@ export function DateColumn(props: BaseColumnProps) { if (!value) { return ""; } - return dayjs.utc(value as string).local().format("YYYY-MM-DD HH:mm"); + return dayjs + .utc(value as string) + .local() + .format("YYYY-MM-DD HH:mm"); }, render: (rawValue) => { const value = props.transform ? props.transform(rawValue) : rawValue; diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts index 096f47609..53573502f 100644 --- a/client/src/utils/filtering.ts +++ b/client/src/utils/filtering.ts @@ -7,6 +7,7 @@ interface TypedCrudFilter { } export function typeFilters(filters: CrudFilter[]): TypedCrudFilter[] { + // Refine exposes broader filter shapes than this table state uses, so narrow once at the boundary. return filters as TypedCrudFilter[]; // <-- Unsafe cast } @@ -16,10 +17,7 @@ export function typeFilters(filters: CrudFilter[]): TypedCrudFilter[] * @param field The field to get the filter values for. * @returns An array of filter values for the given field. */ -export function getFiltersForField( - filters: TypedCrudFilter[], - field: keyof Obj | string, -): string[] { +export function getFiltersForField(filters: TypedCrudFilter[], field: keyof Obj | string): string[] { const filterValues: string[] = []; filters.forEach((filter) => { if (filter.field === field) { @@ -41,6 +39,7 @@ export function removeUndefined(array: (T | undefined)[]): T[] { * The query is broken down into words and the search is performed on each word. */ export function searchMatches(query: string, test: string): boolean { + // Require every typed token to match so incremental search keeps narrowing results. const words = query.toLowerCase().split(" "); return words.every((word) => test.toLowerCase().includes(word)); } diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 1389b837a..968825d17 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -94,18 +94,18 @@ def add_where_clause_search( if value is not None: conditions = [] for value_part in value.split(","): - value_part = value_part.strip() - if len(value_part) == 0: + stripped_value = value_part.strip() + if len(stripped_value) == 0: continue - # Do exact match if value_part is surrounded by quotes - if value_part[0] == '"' and value_part[-1] == '"': - term = value_part[1:-1] + # Do exact match if stripped_value is surrounded by quotes + if stripped_value[0] == '"' and stripped_value[-1] == '"': + term = stripped_value[1:-1] conditions.append(sqlalchemy.or_(*[field == term for field in fields])) # Do prefix match for better index usage else: # Keep the general search index-friendly so the selector/search UX can # scale on larger datasets without forcing full substring scans. - pattern = f"{value_part}%" + pattern = f"{stripped_value}%" conditions.append(sqlalchemy.or_(*[field.ilike(pattern) for field in fields])) if conditions: diff --git a/spoolman/import_externaldb.py b/spoolman/import_externaldb.py index 77eab6106..27e6260fc 100644 --- a/spoolman/import_externaldb.py +++ b/spoolman/import_externaldb.py @@ -4,21 +4,24 @@ import logging from datetime import datetime +from typing import TYPE_CHECKING from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession from spoolman import externaldb from spoolman.api.v1.models import MultiColorDirection from spoolman.database import models +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + logger = logging.getLogger(__name__) def _normalize_hex(value: str | None) -> str | None: if not value: return None - return value[1:] if value.startswith("#") else value + return value.removeprefix("#") async def import_external_filaments( @@ -39,6 +42,7 @@ async def import_external_filaments( logger.info("Fetching external filaments for import.") filaments = await externaldb.fetch_external_filaments() + # Reuse one vendor row per external manufacturer across the whole import pass. existing_vendor_rows = await db.execute(select(models.Vendor)) vendors_by_external_id = { vendor.external_id: vendor for vendor in existing_vendor_rows.scalars().all() if vendor.external_id @@ -74,6 +78,7 @@ async def import_external_filaments( color_hex = _normalize_hex(filament.color_hex) multi_color_hexes = None if filament.color_hexes: + # Keep single-color and multi-color storage aligned with the regular filament create/update rules. normalized = [_normalize_hex(value) for value in filament.color_hexes] multi_color_hexes = ",".join([value for value in normalized if value]) From 80f482d23b78689b28c4bfb947fc6c2502ebb1cc Mon Sep 17 00:00:00 2001 From: akira69 Date: Mon, 16 Mar 2026 17:29:27 -0500 Subject: [PATCH 6/7] refactor: optimize filter dropdown callbacks and consolidate fetch patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrapped selectAllFiltered and selectNoneFiltered in useCallback to prevent unnecessary Button re-renders - Created useSimpleSortedArrayQuery factory function to eliminate duplicate fetch-and-sort patterns - Consolidated 5 repeated fetch hooks (Materials, ArticleNumbers, LotNumbers, Locations) into factory calls - Improved error messages to include endpoint names for better debugging - All code quality checks pass: ESLint ✓, Prettier ✓ --- client/src/components/column.tsx | 10 +-- client/src/components/otherModels.tsx | 103 ++++++++------------------ 2 files changed, 37 insertions(+), 76 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index c02740e65..91efeb2ab 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -5,7 +5,7 @@ import { ColumnFilterItem, ColumnType } from "antd/es/table/interface"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { AlignType } from "rc-table/lib/interface"; -import { Key, useMemo, useState } from "react"; +import { Key, useCallback, useMemo, useState } from "react"; import { Link } from "react-router"; import { getFiltersForField, typeFilters } from "../utils/filtering"; import { enrichText } from "../utils/parsing"; @@ -142,7 +142,7 @@ function FilterDropdownContent(props: { const endIndex = Math.min(filteredItems.length, startIndex + visibleCount + FILTER_DROPDOWN_OVERSCAN * 2); const visibleItems = filteredItems.slice(startIndex, endIndex); - const selectAllFiltered = () => { + const selectAllFiltered = useCallback(() => { if (filteredValues.length === 0) { return; } @@ -154,16 +154,16 @@ function FilterDropdownContent(props: { const existing = new Map(selectedKeys.map((value) => [valueKey(value), value])); filteredValues.forEach((value) => existing.set(valueKey(value), value)); setSelectedKeys(Array.from(existing.values())); - }; + }, [filteredValues, selectedKeys, allowMultipleFilters]); - const selectNoneFiltered = () => { + const selectNoneFiltered = useCallback(() => { if (!allowMultipleFilters) { const firstNonFiltered = selectedKeys.find((value) => !filteredValueKeySet.has(valueKey(value))); setSelectedKeys(firstNonFiltered ? [firstNonFiltered] : []); return; } setSelectedKeys(selectedKeys.filter((value) => !filteredValueKeySet.has(valueKey(value)))); - }; + }, [filteredValueKeySet, selectedKeys, allowMultipleFilters]); return (
diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 81299a48c..57efdbb75 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -4,6 +4,32 @@ import { IFilament } from "../pages/filaments/model"; import { IVendor } from "../pages/vendors/model"; import { getAPIURL } from "../utils/url"; +/** + * Factory function to create a reusable query hook for fetching and sorting string arrays from API endpoints. + * @param queryKey - Unique cache key for react-query + * @param endpoint - API endpoint to fetch from + * @param enabled - Whether the query should be enabled + */ +function useSimpleSortedArrayQuery(queryKey: string[], endpoint: string, enabled: boolean = false) { + return useQuery({ + enabled, + queryKey, + queryFn: async () => { + const response = await fetch(getAPIURL() + endpoint); + if (!response.ok) { + throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`); + } + return response.json(); + }, + select: (data) => { + if (Array.isArray(data)) { + return [...data].sort(); + } + return []; + }, + }); +} + export function useSpoolmanFilamentFilter(enabled: boolean = false) { return useQuery({ enabled: enabled, @@ -53,20 +79,7 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { } export function useSpoolmanFilamentNames(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["filamentNames"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/filament-name"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["filamentNames"], "/filament-name", enabled); } export function useSpoolmanVendors(enabled: boolean = false) { @@ -76,7 +89,7 @@ export function useSpoolmanVendors(enabled: boolean = false) { queryFn: async () => { const response = await fetch(getAPIURL() + "/vendor"); if (!response.ok) { - throw new Error("Network response was not ok"); + throw new Error(`Failed to fetch vendors: ${response.statusText}`); } return response.json(); }, @@ -91,69 +104,17 @@ export function useSpoolmanVendors(enabled: boolean = false) { } export function useSpoolmanMaterials(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["materials"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/material"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["materials"], "/material", enabled); } export function useSpoolmanArticleNumbers(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["articleNumbers"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/article-number"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["articleNumbers"], "/article-number", enabled); } export function useSpoolmanLotNumbers(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["lotNumbers"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/lot-number"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["lotNumbers"], "/lot-number", enabled); } export function useSpoolmanLocations(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["locations"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/location"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }, - select: (data) => { - return data.sort(); - }, - }); + return useSimpleSortedArrayQuery(["locations"], "/location", enabled); } From 05bc286191bc798ad81654b5b918b1833a6f7b74 Mon Sep 17 00:00:00 2001 From: akira69 Date: Sat, 28 Mar 2026 11:34:16 -0500 Subject: [PATCH 7/7] fix: carry forward saved-state guard and trim split artifacts --- client/public/locales/en/common.json | 1 - client/src/components/otherModels.tsx | 27 ++---- client/src/utils/saveload.ts | 13 ++- spoolman/import_externaldb.py | 118 -------------------------- 4 files changed, 20 insertions(+), 139 deletions(-) delete mode 100644 spoolman/import_externaldb.py diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 4688e3a86..13fec930a 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -228,7 +228,6 @@ "diameter": "Diameter", "weight": "Weight", "spool_weight": "Spool Weight", - "spool_count": "Spool Count", "article_number": "Article Number", "registered": "Registered", "comment": "Comment", diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 57efdbb75..ceefb217b 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -4,14 +4,8 @@ import { IFilament } from "../pages/filaments/model"; import { IVendor } from "../pages/vendors/model"; import { getAPIURL } from "../utils/url"; -/** - * Factory function to create a reusable query hook for fetching and sorting string arrays from API endpoints. - * @param queryKey - Unique cache key for react-query - * @param endpoint - API endpoint to fetch from - * @param enabled - Whether the query should be enabled - */ -function useSimpleSortedArrayQuery(queryKey: string[], endpoint: string, enabled: boolean = false) { - return useQuery({ +function useSimpleSortedArrayQuery(queryKey: string[], endpoint: string, enabled: boolean = false) { + return useQuery({ enabled, queryKey, queryFn: async () => { @@ -21,12 +15,7 @@ function useSimpleSortedArrayQuery(queryKey: string[], endpoint: string, enab } return response.json(); }, - select: (data) => { - if (Array.isArray(data)) { - return [...data].sort(); - } - return []; - }, + select: (data) => [...data].sort(), }); } @@ -79,7 +68,7 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { } export function useSpoolmanFilamentNames(enabled: boolean = false) { - return useSimpleSortedArrayQuery(["filamentNames"], "/filament-name", enabled); + return useSimpleSortedArrayQuery(["filamentNames"], "/filament-name", enabled); } export function useSpoolmanVendors(enabled: boolean = false) { @@ -104,17 +93,17 @@ export function useSpoolmanVendors(enabled: boolean = false) { } export function useSpoolmanMaterials(enabled: boolean = false) { - return useSimpleSortedArrayQuery(["materials"], "/material", enabled); + return useSimpleSortedArrayQuery(["materials"], "/material", enabled); } export function useSpoolmanArticleNumbers(enabled: boolean = false) { - return useSimpleSortedArrayQuery(["articleNumbers"], "/article-number", enabled); + return useSimpleSortedArrayQuery(["articleNumbers"], "/article-number", enabled); } export function useSpoolmanLotNumbers(enabled: boolean = false) { - return useSimpleSortedArrayQuery(["lotNumbers"], "/lot-number", enabled); + return useSimpleSortedArrayQuery(["lotNumbers"], "/lot-number", enabled); } export function useSpoolmanLocations(enabled: boolean = false) { - return useSimpleSortedArrayQuery(["locations"], "/location", enabled); + return useSimpleSortedArrayQuery(["locations"], "/location", enabled); } diff --git a/client/src/utils/saveload.ts b/client/src/utils/saveload.ts index f3ba0fc6f..66ff7120f 100644 --- a/client/src/utils/saveload.ts +++ b/client/src/utils/saveload.ts @@ -6,6 +6,17 @@ interface Pagination { pageSize: number; } +function parseSavedJSON(value: string | null, fallback: T): T { + if (!value) { + return fallback; + } + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + export interface TableState { sorters: CrudSort[]; filters: CrudFilter[]; @@ -93,7 +104,7 @@ export function useStoreInitialState(tableId: string, state: TableState) { export function useSavedState(id: string, defaultValue: T) { const [state, setState] = useState(() => { const savedState = isLocalStorageAvailable ? localStorage.getItem(`savedStates-${id}`) : null; - return savedState ? JSON.parse(savedState) : defaultValue; + return parseSavedJSON(savedState, defaultValue); }); useEffect(() => { diff --git a/spoolman/import_externaldb.py b/spoolman/import_externaldb.py deleted file mode 100644 index 27e6260fc..000000000 --- a/spoolman/import_externaldb.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Import filaments from the external database into the local DB.""" - -from __future__ import annotations - -import logging -from datetime import datetime -from typing import TYPE_CHECKING - -from sqlalchemy import func, select - -from spoolman import externaldb -from spoolman.api.v1.models import MultiColorDirection -from spoolman.database import models - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -logger = logging.getLogger(__name__) - - -def _normalize_hex(value: str | None) -> str | None: - if not value: - return None - return value.removeprefix("#") - - -async def import_external_filaments( - db: AsyncSession, - *, - only_if_empty: bool = True, -) -> int: - """Import external filaments into the local database. - - Returns the number of imported filaments. - """ - if only_if_empty: - existing_count = await db.scalar(select(func.count(models.Filament.id))) - if existing_count and existing_count > 0: - logger.info("Skipping external DB import because filaments already exist (%d).", existing_count) - return 0 - - logger.info("Fetching external filaments for import.") - filaments = await externaldb.fetch_external_filaments() - - # Reuse one vendor row per external manufacturer across the whole import pass. - existing_vendor_rows = await db.execute(select(models.Vendor)) - vendors_by_external_id = { - vendor.external_id: vendor for vendor in existing_vendor_rows.scalars().all() if vendor.external_id - } - - existing_filament_rows = await db.execute( - select(models.Filament.external_id).where(models.Filament.external_id.is_not(None)), - ) - existing_filament_ids = {row[0] for row in existing_filament_rows.all() if row[0]} - - now = datetime.utcnow().replace(microsecond=0) - imported = 0 - - for filament in filaments: - if filament.id in existing_filament_ids: - continue - - manufacturer = filament.manufacturer.strip() - vendor_item = vendors_by_external_id.get(manufacturer) - if vendor_item is None: - vendor_item = models.Vendor( - name=manufacturer, - registered=now, - comment=None, - empty_spool_weight=None, - external_id=manufacturer, - extra=[], - ) - db.add(vendor_item) - await db.flush() - vendors_by_external_id[manufacturer] = vendor_item - - color_hex = _normalize_hex(filament.color_hex) - multi_color_hexes = None - if filament.color_hexes: - # Keep single-color and multi-color storage aligned with the regular filament create/update rules. - normalized = [_normalize_hex(value) for value in filament.color_hexes] - multi_color_hexes = ",".join([value for value in normalized if value]) - - multi_color_direction = None - if filament.multi_color_direction is not None: - multi_color_direction = MultiColorDirection(filament.multi_color_direction.value) - - spool_weight = filament.spool_weight - if spool_weight is None and vendor_item.empty_spool_weight is not None: - spool_weight = vendor_item.empty_spool_weight - - db_item = models.Filament( - registered=now, - name=filament.name, - vendor=vendor_item, - material=filament.material, - price=None, - density=filament.density, - diameter=filament.diameter, - weight=filament.weight, - spool_weight=spool_weight, - article_number=None, - comment=None, - settings_extruder_temp=filament.extruder_temp, - settings_bed_temp=filament.bed_temp, - color_hex=color_hex if filament.color_hex else None, - multi_color_hexes=None if filament.color_hex else multi_color_hexes, - multi_color_direction=multi_color_direction.value if multi_color_direction else None, - external_id=filament.id, - extra=[], - ) - db.add(db_item) - imported += 1 - - await db.commit() - logger.info("Imported %d external filaments.", imported) - return imported