diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..13fec930a 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": { diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 059b607f0..91efeb2ab 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, useCallback, 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,308 @@ 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(); + } + 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 { + // 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") { + 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; +}) { + // 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 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]); + + // 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); + const visibleItems = filteredItems.slice(startIndex, endIndex); + + const selectAllFiltered = useCallback(() => { + 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())); + }, [filteredValues, selectedKeys, allowMultipleFilters]); + + 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 ( +
+ { + 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; +}) { + // 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]) : ""; + + return ( +
+ { + const value = event.target.value; + setSelectedKeys(value ? [value] : []); + }} + onPressEnter={() => confirm()} + /> + + + + +
+ ); +} + interface Entity { id: number; } @@ -46,6 +353,9 @@ interface BaseColumnProps { title?: string; align?: AlignType; sorter?: boolean; + searchable?: boolean; + searchPlaceholder?: string; + searchValueFormatter?: (rawValue: unknown, record: Obj) => string; t: (key: string) => string; navigate: (link: string) => void; dataSource: Obj[]; @@ -54,6 +364,7 @@ interface BaseColumnProps { actions?: (record: Obj) => Action[]; transform?: (value: unknown) => unknown; render?: (rawValue: string | undefined, record: Obj) => React.ReactNode; + ellipsis?: boolean; } interface FilteredColumnProps { @@ -93,6 +404,7 @@ function Column( filterMultiple: props.allowMultipleFilters ?? true, width: props.width ?? undefined, onCell: props.onCell ?? undefined, + ellipsis: props.ellipsis ?? false, }; // Sorting @@ -108,9 +420,22 @@ function Column( if (props.filters && props.filteredValue) { columnProps.filters = props.filters; columnProps.filteredValue = props.filteredValue; - if (props.loadingFilters) { - columnProps.filterDropdown = ; - } + columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => { + if (props.loadingFilters) { + return ; + } + return ( + + ); + }; columnProps.filterDropdownProps = { onOpenChange: (open) => { if (open && props.onFilterDropdownOpen) { @@ -121,6 +446,72 @@ function Column( if (props.dataId) { columnProps.key = props.dataId; } + } else if (props.searchable) { + 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); + 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 + ? 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 @@ -170,6 +561,7 @@ export function SortedColumn(props: BaseColumnProps) { return Column({ ...props, sorter: true, + searchable: props.searchable ?? true, }); } @@ -178,6 +570,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); @@ -188,6 +581,8 @@ export function RichColumn( interface FilteredQueryColumnProps extends BaseColumnProps { filterValueQuery: UseQueryResult; allowMultipleFilters?: boolean; + includeEmptyFilter?: boolean; + emptyFilterLabel?: string; } export function FilteredQueryColumn(props: FilteredQueryColumnProps) { @@ -205,19 +600,30 @@ 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(); + // Defer distinct-value fetches until the user opens the dropdown to avoid eager list-page traffic. + 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 { @@ -231,6 +637,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) { @@ -253,6 +660,17 @@ 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 ( @@ -366,6 +784,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 0adabd80c..ceefb217b 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -1,10 +1,24 @@ 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"; import { getAPIURL } from "../utils/url"; +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) => [...data].sort(), + }); +} + export function useSpoolmanFilamentFilter(enabled: boolean = false) { return useQuery({ enabled: enabled, @@ -25,55 +39,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 @@ -86,31 +68,7 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { } export function useSpoolmanFilamentNames(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["filaments"], - queryFn: async () => { - const response = await fetch(getAPIURL() + "/filament"); - 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 useSimpleSortedArrayQuery(["filamentNames"], "/filament-name", enabled); } export function useSpoolmanVendors(enabled: boolean = false) { @@ -120,7 +78,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(); }, @@ -135,69 +93,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); } diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts index da99b43c4..53573502f 100644 --- a/client/src/utils/filtering.ts +++ b/client/src/utils/filtering.ts @@ -1,12 +1,13 @@ import { CrudFilter, CrudOperators } from "@refinedev/core"; interface TypedCrudFilter { - field: keyof Obj; + field: keyof Obj | string; operator: Exclude; value: string[]; } 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: Field, -): 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/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/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..3d41584e8 --- /dev/null +++ b/migrations/versions/2026_02_11_1700-b76f1b4c3f5a_filament_search_indexes.py @@ -0,0 +1,59 @@ +"""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.""" + 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")} + + 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.""" + 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 new file mode 100644 index 000000000..6e5047ebc --- /dev/null +++ b/migrations/versions/2026_02_11_1710-f1a3d9c2c4e1_spool_search_indexes.py @@ -0,0 +1,41 @@ +"""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.""" + 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: + 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.""" + 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") 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..968825d17 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -85,6 +85,34 @@ 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(","): + stripped_value = value_part.strip() + if len(stripped_value) == 0: + continue + # 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"{stripped_value}%" + 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],