feat: Add color column with filter to spool list
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Add optional color column to spool list showing filament color swatches with multi-select filtering capability. Backend: - GET /api/v1/color endpoint to list distinct filament colors - filament.color_hex query param on spool list for filtering Frontend: - ColorFilterColumn component renders color swatches - useSpoolmanColors() hook fetches available colors - Filter dropdown shows swatches with hex codes - Column hidden by default (enable via column selector) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
49
CLAUDE.md
49
CLAUDE.md
@@ -122,7 +122,7 @@ Environment variables (see `.env.example`):
|
|||||||
|
|
||||||
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
|
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
|
||||||
|
|
||||||
## UX Improvement Issues
|
## UX Improvement Issues (Gitea)
|
||||||
|
|
||||||
| # | Title | Status |
|
| # | Title | Status |
|
||||||
|---|-------|--------|
|
|---|-------|--------|
|
||||||
@@ -132,16 +132,23 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
|
|||||||
| 4 | Layout Max-Width | ✅ DONE |
|
| 4 | Layout Max-Width | ✅ DONE |
|
||||||
| 5 | Temperature Ranges | ✅ DONE |
|
| 5 | Temperature Ranges | ✅ DONE |
|
||||||
| 6 | Spool Adjustment History | ✅ DONE |
|
| 6 | Spool Adjustment History | ✅ DONE |
|
||||||
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED (bot protection) |
|
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
|
||||||
| 8 | (see Gitea #8) | ✅ DONE |
|
| 8 | (duplicate of #7) | ✅ DONE |
|
||||||
| 9 | Hierarchical Locations (Room/Bin) | Open |
|
| 9 | Hierarchical Locations (Room/Bin) | **Open** |
|
||||||
| 10 | Database Import/Export | ✅ DONE |
|
| 10 | Database Import/Export | ✅ DONE |
|
||||||
| 11 | Theme Colors | ✅ DONE |
|
| 11 | Slicer Integration / Print Jobs | ✅ DONE |
|
||||||
| 12 | Clickable Dashboard Cards | ✅ DONE |
|
| 12 | Redesign Home Page Dashboard | **Open** |
|
||||||
| 13 | QR Code Size Setting | ✅ DONE |
|
| 13 | Batch Operations (Multi-Select Edit) | **Open** |
|
||||||
| 14 | Parse Temps from Comments | ✅ DONE |
|
| 14 | Extra Weight Field (DryPods, etc.) | **Open** |
|
||||||
| 15 | Print Job Tracking / Slicer Integration | ✅ DONE |
|
| 15 | Spool Count per Filament in List | **Open** |
|
||||||
| 16 | Prev/Next Navigation on Edit Pages | ✅ DONE |
|
|
||||||
|
### Other Features (not tracked in Gitea)
|
||||||
|
- ✅ Theme Colors (5 options)
|
||||||
|
- ✅ Clickable Dashboard Cards
|
||||||
|
- ✅ QR Code Size Setting
|
||||||
|
- ✅ Parse Temps from Comments
|
||||||
|
- ✅ Prev/Next Navigation on Edit Pages
|
||||||
|
- ✅ Color Column in Spool List (with filter)
|
||||||
|
|
||||||
### Issue #1 Complete - Inline Creation Modals
|
### Issue #1 Complete - Inline Creation Modals
|
||||||
|
|
||||||
@@ -326,6 +333,28 @@ Track pending print jobs from slicer and manage filament deduction after prints
|
|||||||
- Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID`
|
- Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID`
|
||||||
- Or config file: `~/.config/spoolman/slicer.conf`
|
- Or config file: `~/.config/spoolman/slicer.conf`
|
||||||
|
|
||||||
|
### Color Column in Spool List
|
||||||
|
|
||||||
|
Added optional color column to the spool list with filtering capability.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/database/filament.py` - `find_colors()` function to get distinct color hex codes
|
||||||
|
- `spoolman/api/v1/other.py` - `GET /api/v1/color` endpoint to list all colors
|
||||||
|
- `spoolman/api/v1/spool.py` - Added `filament.color_hex` query parameter for filtering
|
||||||
|
- `spoolman/database/spool.py` - Filter implementation (supports comma-separated values)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/components/column.tsx` - `ColorFilterColumn` component renders color swatches
|
||||||
|
- `client/src/components/otherModels.tsx` - `useSpoolmanColors()` hook to fetch colors
|
||||||
|
- `client/src/pages/spools/list.tsx` - Added color column (hidden by default)
|
||||||
|
- `client/public/locales/en/common.json` - Added "Color" translation key
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Color displayed as small swatch with hex tooltip
|
||||||
|
- Filter dropdown shows swatches with hex codes
|
||||||
|
- Multi-select filtering (can filter by multiple colors)
|
||||||
|
- Column hidden by default (enable via column selector)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|||||||
@@ -173,6 +173,7 @@
|
|||||||
"filament_internal": "Internal",
|
"filament_internal": "Internal",
|
||||||
"filament_external": "External",
|
"filament_external": "External",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
|
"color": "Color",
|
||||||
"material": "Material",
|
"material": "Material",
|
||||||
"weight_to_use": "Weight",
|
"weight_to_use": "Weight",
|
||||||
"used_weight": "Used Weight",
|
"used_weight": "Used Weight",
|
||||||
|
|||||||
@@ -220,6 +220,52 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
|
|||||||
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
|
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ColorFilterColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
||||||
|
filterValueQuery: UseQueryResult<ColumnFilterItem[], unknown>;
|
||||||
|
colorGetter: (record: Obj) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorFilterColumn<Obj extends Entity>(props: ColorFilterColumnProps<Obj>) {
|
||||||
|
const query = props.filterValueQuery;
|
||||||
|
|
||||||
|
let filters: ColumnFilterItem[] = [];
|
||||||
|
if (query.data) {
|
||||||
|
filters = query.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedFilters = typeFilters<Obj>(props.tableState.filters);
|
||||||
|
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
|
||||||
|
|
||||||
|
const onFilterDropdownOpen = () => {
|
||||||
|
query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return Column({
|
||||||
|
...props,
|
||||||
|
filters,
|
||||||
|
filteredValue,
|
||||||
|
onFilterDropdownOpen,
|
||||||
|
loadingFilters: query.isLoading,
|
||||||
|
allowMultipleFilters: true,
|
||||||
|
render: (_: unknown, record: Obj) => {
|
||||||
|
const colorHex = props.colorGetter(record);
|
||||||
|
if (!colorHex) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "1.5em",
|
||||||
|
height: "1.5em",
|
||||||
|
backgroundColor: "#" + colorHex,
|
||||||
|
borderRadius: "3px",
|
||||||
|
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
|
||||||
|
}}
|
||||||
|
title={"#" + colorHex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
||||||
unit: string;
|
unit: string;
|
||||||
maxDecimals?: number;
|
maxDecimals?: number;
|
||||||
|
|||||||
@@ -203,3 +203,36 @@ export function useSpoolmanLocations(enabled: boolean = false) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSpoolmanColors(enabled: boolean = false) {
|
||||||
|
return useQuery<string[], unknown, ColumnFilterItem[]>({
|
||||||
|
enabled: enabled,
|
||||||
|
queryKey: ["colors"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(getAPIURL() + "/color");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Network response was not ok");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
select: (data) => {
|
||||||
|
return data.map((colorHex) => ({
|
||||||
|
text: (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "1.2em",
|
||||||
|
height: "1.2em",
|
||||||
|
backgroundColor: "#" + colorHex,
|
||||||
|
borderRadius: "2px",
|
||||||
|
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}>#{colorHex}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: colorHex,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useNavigate } from "react-router";
|
|||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
ActionsColumn,
|
ActionsColumn,
|
||||||
|
ColorFilterColumn,
|
||||||
CustomFieldColumn,
|
CustomFieldColumn,
|
||||||
DateColumn,
|
DateColumn,
|
||||||
FilteredQueryColumn,
|
FilteredQueryColumn,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from "../../components/column";
|
} from "../../components/column";
|
||||||
import { useLiveify } from "../../components/liveify";
|
import { useLiveify } from "../../components/liveify";
|
||||||
import {
|
import {
|
||||||
|
useSpoolmanColors,
|
||||||
useSpoolmanFilamentFilter,
|
useSpoolmanFilamentFilter,
|
||||||
useSpoolmanLocations,
|
useSpoolmanLocations,
|
||||||
useSpoolmanLotNumbers,
|
useSpoolmanLotNumbers,
|
||||||
@@ -48,6 +50,7 @@ interface ISpoolCollapsed extends ISpool {
|
|||||||
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
|
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
|
||||||
"filament.id": number;
|
"filament.id": number;
|
||||||
"filament.material"?: string;
|
"filament.material"?: string;
|
||||||
|
"filament.color_hex"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseSpool(element: ISpool): ISpoolCollapsed {
|
function collapseSpool(element: ISpool): ISpoolCollapsed {
|
||||||
@@ -65,6 +68,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
|
|||||||
"filament.combined_name": filament_name,
|
"filament.combined_name": filament_name,
|
||||||
"filament.id": element.filament.id,
|
"filament.id": element.filament.id,
|
||||||
"filament.material": element.filament.material,
|
"filament.material": element.filament.material,
|
||||||
|
"filament.color_hex": element.filament.color_hex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ function translateColumnI18nKey(columnName: string): string {
|
|||||||
columnName = columnName.replace(".", "_");
|
columnName = columnName.replace(".", "_");
|
||||||
if (columnName === "filament_combined_name") columnName = "filament_name";
|
if (columnName === "filament_combined_name") columnName = "filament_name";
|
||||||
else if (columnName === "filament_material") columnName = "material";
|
else if (columnName === "filament_material") columnName = "material";
|
||||||
|
else if (columnName === "filament_color_hex") columnName = "color";
|
||||||
return `spool.fields.${columnName}`;
|
return `spool.fields.${columnName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +85,7 @@ const namespace = "spoolList-v2";
|
|||||||
const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
||||||
"id",
|
"id",
|
||||||
"filament.combined_name",
|
"filament.combined_name",
|
||||||
|
"filament.color_hex",
|
||||||
"filament.material",
|
"filament.material",
|
||||||
"price",
|
"price",
|
||||||
"used_weight",
|
"used_weight",
|
||||||
@@ -94,7 +100,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
|||||||
"comment",
|
"comment",
|
||||||
];
|
];
|
||||||
const defaultColumns = allColumns.filter(
|
const defaultColumns = allColumns.filter(
|
||||||
(column_id) => ["registered", "used_length", "remaining_length", "lot_nr"].indexOf(column_id) === -1
|
(column_id) => ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex"].indexOf(column_id) === -1
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||||
@@ -367,6 +373,14 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
dataId: "filament.combined_name",
|
dataId: "filament.combined_name",
|
||||||
filterValueQuery: useSpoolmanFilamentFilter(),
|
filterValueQuery: useSpoolmanFilamentFilter(),
|
||||||
}),
|
}),
|
||||||
|
ColorFilterColumn({
|
||||||
|
...commonProps,
|
||||||
|
id: "filament.color_hex",
|
||||||
|
i18nkey: "spool.fields.color",
|
||||||
|
filterValueQuery: useSpoolmanColors(),
|
||||||
|
colorGetter: (record: ISpoolCollapsed) => record.filament.color_hex,
|
||||||
|
width: 60,
|
||||||
|
}),
|
||||||
FilteredQueryColumn({
|
FilteredQueryColumn({
|
||||||
...commonProps,
|
...commonProps,
|
||||||
id: "filament.material",
|
id: "filament.material",
|
||||||
|
|||||||
@@ -47,6 +47,33 @@ async def find_materials(
|
|||||||
return await filament.find_materials(db=db)
|
return await filament.find_materials(db=db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/color",
|
||||||
|
name="Find colors",
|
||||||
|
description="Get a list of all filament colors (hex codes).",
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "A list of all filament color hex codes.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": [
|
||||||
|
"FF0000",
|
||||||
|
"00FF00",
|
||||||
|
"0000FF",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def find_colors(
|
||||||
|
*,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> list[str]:
|
||||||
|
return await filament.find_colors(db=db)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/article-number",
|
"/article-number",
|
||||||
name="Find article numbers",
|
name="Find article numbers",
|
||||||
|
|||||||
@@ -212,6 +212,17 @@ async def find(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
|
filament_color_hex: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
Query(
|
||||||
|
alias="filament.color_hex",
|
||||||
|
title="Filament Color",
|
||||||
|
description=(
|
||||||
|
"Filter by exact filament color hex code (without #). Separate multiple colors with a comma."
|
||||||
|
),
|
||||||
|
examples=["FF0000", "FF0000,00FF00,0000FF"],
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
filament_vendor_name: Annotated[
|
filament_vendor_name: Annotated[
|
||||||
Optional[str],
|
Optional[str],
|
||||||
Query(
|
Query(
|
||||||
@@ -321,6 +332,7 @@ async def find(
|
|||||||
filament_name=filament_name if filament_name is not None else filament_name_old,
|
filament_name=filament_name if filament_name is not None else filament_name_old,
|
||||||
filament_id=filament_ids,
|
filament_id=filament_ids,
|
||||||
filament_material=filament_material if filament_material is not None else filament_material_old,
|
filament_material=filament_material if filament_material is not None else filament_material_old,
|
||||||
|
filament_color_hex=filament_color_hex,
|
||||||
vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
|
vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
|
||||||
vendor_id=filament_vendor_ids,
|
vendor_id=filament_vendor_ids,
|
||||||
location=location,
|
location=location,
|
||||||
|
|||||||
@@ -241,6 +241,16 @@ async def find_materials(
|
|||||||
return [row[0] for row in rows.all() if row[0] is not None]
|
return [row[0] for row in rows.all() if row[0] is not None]
|
||||||
|
|
||||||
|
|
||||||
|
async def find_colors(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Find a list of unique filament colors (hex codes) from the filament table."""
|
||||||
|
stmt = select(models.Filament.color_hex).distinct()
|
||||||
|
rows = await db.execute(stmt)
|
||||||
|
return sorted([row[0] for row in rows.all() if row[0] is not None])
|
||||||
|
|
||||||
|
|
||||||
async def find_article_numbers(
|
async def find_article_numbers(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ async def find( # noqa: C901, PLR0912
|
|||||||
filament_name: Optional[str] = None,
|
filament_name: Optional[str] = None,
|
||||||
filament_id: Optional[Union[int, Sequence[int]]] = None,
|
filament_id: Optional[Union[int, Sequence[int]]] = None,
|
||||||
filament_material: Optional[str] = None,
|
filament_material: Optional[str] = None,
|
||||||
|
filament_color_hex: Optional[Union[str, Sequence[str]]] = None,
|
||||||
vendor_name: Optional[str] = None,
|
vendor_name: Optional[str] = None,
|
||||||
vendor_id: Optional[Union[int, Sequence[int]]] = None,
|
vendor_id: Optional[Union[int, Sequence[int]]] = None,
|
||||||
location: Optional[str] = None,
|
location: Optional[str] = None,
|
||||||
@@ -152,6 +153,15 @@ async def find( # noqa: C901, PLR0912
|
|||||||
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
|
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
|
||||||
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
|
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
|
||||||
|
|
||||||
|
# Filter by color_hex (exact match, supports multiple comma-separated values)
|
||||||
|
if filament_color_hex is not None:
|
||||||
|
if isinstance(filament_color_hex, str):
|
||||||
|
colors = [c.strip() for c in filament_color_hex.split(",") if c.strip()]
|
||||||
|
else:
|
||||||
|
colors = list(filament_color_hex)
|
||||||
|
if colors:
|
||||||
|
stmt = stmt.where(models.Filament.color_hex.in_(colors))
|
||||||
|
|
||||||
if not allow_archived:
|
if not allow_archived:
|
||||||
# Since the archived field is nullable, and default is false, we need to check for both false or null
|
# Since the archived field is nullable, and default is false, we need to check for both false or null
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
|
|||||||
Reference in New Issue
Block a user