Merge pull request #256 from Donkie/custom-fields

New backend custom fields system
This commit is contained in:
Donkie
2024-01-23 19:37:33 +01:00
committed by GitHub
59 changed files with 3557 additions and 372 deletions

View File

@@ -41,7 +41,9 @@
"deleteError": "Error when deleting {{resource}} (status code: {{statusCode}})", "deleteError": "Error when deleting {{resource}} (status code: {{statusCode}})",
"editSuccess": "Successfully edited {{resource}}", "editSuccess": "Successfully edited {{resource}}",
"editError": "Error when editing {{resource}} (status code: {{statusCode}})", "editError": "Error when editing {{resource}} (status code: {{statusCode}})",
"importProgress": "Importing: {{processed}}/{{total}}" "importProgress": "Importing: {{processed}}/{{total}}",
"saveSuccessful": "Save successful!",
"validationError": "Validation error: {{error}}"
}, },
"kofi": "Tip me on Ko-fi", "kofi": "Tip me on Ko-fi",
"loading": "Loading", "loading": "Loading",
@@ -252,6 +254,46 @@
"table": { "table": {
"actions": "Actions" "actions": "Actions"
}, },
"settings": {
"header": "Settings",
"general": {
"tab": "General",
"currency": {
"label": "Currency"
}
},
"extra_fields": {
"tab": "Extra Fields",
"description": "<p>Here you can add extra custom fields to your entities.</p><p>Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
"params": {
"key": "Key",
"name": "Name",
"field_type": "Type",
"unit": "Unit",
"order": "Order",
"default_value": "Default Value",
"choices": "Choices",
"multi_choice": "Multi Choice"
},
"field_type": {
"text": "Text",
"integer": "Integer",
"integer_range": "Integer Range",
"float": "Float",
"float_range": "Float Range",
"datetime": "Datetime",
"boolean": "Boolean",
"choice": "Choice"
},
"boolean_true": "Yes",
"boolean_false": "No",
"choices_missing_error": "You cannot remove choices. The following choices are missing: {{choices}}",
"non_unique_key_error": "The key must be unique.",
"key_not_changed": "Please change the key to something else.",
"delete_confirm": "Delete field {{name}}?",
"delete_confirm_description": "This will delete the field and all associated data for all entities."
}
},
"documentTitle": { "documentTitle": {
"default": "Spoolman", "default": "Spoolman",
"suffix": " | Spoolman", "suffix": " | Spoolman",

View File

@@ -41,7 +41,8 @@
"deleteError": "Det gick inte att ta bort {{resource}} (statuskod: {{statusCode}})", "deleteError": "Det gick inte att ta bort {{resource}} (statuskod: {{statusCode}})",
"editSuccess": "Lyckades ändra {{resource}}", "editSuccess": "Lyckades ändra {{resource}}",
"editError": "Det gick inte att ändra {{resource}} (statuskod: {{statusCode}})", "editError": "Det gick inte att ändra {{resource}} (statuskod: {{statusCode}})",
"importProgress": "Importerar: {{processed}}/{{total}}" "importProgress": "Importerar: {{processed}}/{{total}}",
"saveSuccessful": "Sparning lyckades!"
}, },
"loading": "Laddar", "loading": "Laddar",
"version": "Version", "version": "Version",
@@ -240,6 +241,18 @@
"table": { "table": {
"actions": "Åtgärder" "actions": "Åtgärder"
}, },
"settings": {
"header": "Inställningar",
"generic": {
"tab": "Allmänt",
"currency": {
"label": "Valuta"
}
},
"extra_fields": {
"tab": "Extra Fält"
}
},
"documentTitle": { "documentTitle": {
"default": "Spoolman", "default": "Spoolman",
"suffix": " | Spoolman", "suffix": " | Spoolman",

View File

@@ -10,7 +10,14 @@ import dataProvider from "./components/dataProvider";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom"; import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { ColorModeContextProvider } from "./contexts/color-mode"; import { ColorModeContextProvider } from "./contexts/color-mode";
import { FileOutlined, HighlightOutlined, HomeOutlined, QuestionOutlined, UserOutlined } from "@ant-design/icons"; import {
FileOutlined,
HighlightOutlined,
HomeOutlined,
QuestionOutlined,
ToolOutlined,
UserOutlined,
} from "@ant-design/icons";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import React from "react"; import React from "react";
import { Locale } from "antd/es/locale"; import { Locale } from "antd/es/locale";
@@ -139,6 +146,14 @@ function App() {
icon: <UserOutlined />, icon: <UserOutlined />,
}, },
}, },
{
name: "settings",
list: "/settings",
meta: {
canDelete: false,
icon: <ToolOutlined />,
},
},
{ {
name: "help", name: "help",
list: "/help", list: "/help",
@@ -202,6 +217,7 @@ function App() {
<Route path="edit/:id" element={<LoadableResourcePage resource="vendors" page="edit" />} /> <Route path="edit/:id" element={<LoadableResourcePage resource="vendors" page="edit" />} />
<Route path="show/:id" element={<LoadableResourcePage resource="vendors" page="show" />} /> <Route path="show/:id" element={<LoadableResourcePage resource="vendors" page="show" />} />
</Route> </Route>
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
<Route path="/help" element={<LoadablePage name="help" />} /> <Route path="/help" element={<LoadablePage name="help" />} />
<Route path="*" element={<ErrorComponent />} /> <Route path="*" element={<ErrorComponent />} />
</Route> </Route>

View File

@@ -3,7 +3,7 @@ import { ColumnFilterItem, ColumnType } from "antd/es/table/interface";
import { getFiltersForField, typeFilters } from "../utils/filtering"; import { getFiltersForField, typeFilters } from "../utils/filtering";
import { TableState } from "../utils/saveload"; import { TableState } from "../utils/saveload";
import { getSortOrderForField, typeSorters } from "../utils/sorting"; import { getSortOrderForField, typeSorters } from "../utils/sorting";
import { NumberFieldUnit } from "./numberField"; import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { DateField, TextField } from "@refinedev/antd"; import { DateField, TextField } from "@refinedev/antd";
@@ -12,7 +12,8 @@ import SpoolIcon from "../icon_spool.svg?react";
import { useTranslate } from "@refinedev/core"; import { useTranslate } from "@refinedev/core";
import { enrichText } from "../utils/parsing"; import { enrichText } from "../utils/parsing";
import { UseQueryResult } from "@tanstack/react-query"; import { UseQueryResult } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom"; import { Link } from "react-router-dom";
import { Field, FieldType } from "../utils/queryFields";
dayjs.extend(utc); dayjs.extend(utc);
@@ -39,14 +40,19 @@ export interface Action {
} }
interface BaseColumnProps<Obj extends Entity> { interface BaseColumnProps<Obj extends Entity> {
id: keyof Obj & string; id: string | string[];
dataId?: keyof Obj & string; dataId?: keyof Obj & string;
i18ncat?: string; i18ncat?: string;
i18nkey?: string; i18nkey?: string;
title?: string;
sorter?: boolean;
t: (key: string) => string;
navigate: (link: string) => void;
dataSource: Obj[]; dataSource: Obj[];
tableState: TableState; tableState: TableState;
width?: number; width?: number;
actions?: (record: Obj) => Action[]; actions?: (record: Obj) => Action[];
transform?: (value: unknown) => unknown;
} }
interface FilteredColumnProps { interface FilteredColumnProps {
@@ -70,24 +76,32 @@ interface CustomColumnProps<Obj> {
function Column<Obj extends Entity>( function Column<Obj extends Entity>(
props: BaseColumnProps<Obj> & FilteredColumnProps & CustomColumnProps<Obj> props: BaseColumnProps<Obj> & FilteredColumnProps & CustomColumnProps<Obj>
): ColumnType<Obj> | undefined { ): ColumnType<Obj> | undefined {
const t = useTranslate(); const t = props.t;
const navigate = useNavigate(); const navigate = props.navigate;
// Hide if not in showColumns // Hide if not in showColumns
if (props.tableState.showColumns && !props.tableState.showColumns.includes(props.id)) { const id = Array.isArray(props.id) ? props.id.join(".") : props.id;
if (props.tableState.showColumns && !props.tableState.showColumns.includes(id)) {
return undefined; return undefined;
} }
const columnProps: ColumnType<Obj> = { const columnProps: ColumnType<Obj> = {
dataIndex: props.id, dataIndex: props.id,
title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`), title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
sorter: true,
sortOrder: getSortOrderForField(typeSorters<Obj>(props.tableState.sorters), props.dataId ?? props.id),
filterMultiple: props.allowMultipleFilters ?? true, filterMultiple: props.allowMultipleFilters ?? true,
width: props.width ?? undefined, width: props.width ?? undefined,
onCell: props.onCell ?? undefined, onCell: props.onCell ?? undefined,
}; };
// Sorting
if (props.sorter) {
columnProps.sorter = true;
columnProps.sortOrder = getSortOrderForField(
typeSorters<Obj>(props.tableState.sorters),
props.dataId ?? (props.id as keyof Obj)
);
}
// Filter // Filter
if (props.filters && props.filteredValue) { if (props.filters && props.filteredValue) {
columnProps.filters = props.filters; columnProps.filters = props.filters;
@@ -106,7 +120,12 @@ function Column<Obj extends Entity>(
} }
// Render // Render
const render = props.render ?? ((value) => <>{value}</>); const render =
props.render ??
((rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return <>{value}</>;
});
columnProps.render = (value, record, index) => { columnProps.render = (value, record, index) => {
if (!props.actions) { if (!props.actions) {
return render(value, record, index); return render(value, record, index);
@@ -144,13 +163,19 @@ function Column<Obj extends Entity>(
} }
export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) { export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column(props);
}
export function RichColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({ return Column({
...props, ...props,
render: (value: string | undefined) => { sorter: true,
});
}
export function RichColumn<Obj extends Entity>(
props: Omit<BaseColumnProps<Obj>, "transform"> & { transform?: (value: unknown) => string }
) {
return Column({
...props,
render: (rawValue: string | undefined) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return enrichText(value); return enrichText(value);
}, },
}); });
@@ -182,7 +207,7 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
}); });
const typedFilters = typeFilters<Obj>(props.tableState.filters); const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id); const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => { const onFilterDropdownOpen = () => {
query.refetch(); query.refetch();
@@ -193,14 +218,16 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> { interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
unit: string; unit: string;
decimals?: number; maxDecimals?: number;
minDecimals?: number;
defaultText?: string; defaultText?: string;
} }
export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) { export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
return Column({ return Column({
...props, ...props,
render: (value) => { render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) { if (value === null || value === undefined) {
return <TextField value={props.defaultText ?? ""} />; return <TextField value={props.defaultText ?? ""} />;
} }
@@ -209,8 +236,8 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
value={value} value={value}
unit={props.unit} unit={props.unit}
options={{ options={{
maximumFractionDigits: props.decimals ?? 0, maximumFractionDigits: props.maxDecimals ?? 0,
minimumFractionDigits: props.decimals ?? 0, minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0,
}} }}
/> />
); );
@@ -221,7 +248,8 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) { export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({ return Column({
...props, ...props,
render: (value) => { render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return ( return (
<DateField <DateField
hidden={!value} hidden={!value}
@@ -291,7 +319,7 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
}); });
const typedFilters = typeFilters<Obj>(props.tableState.filters); const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id); const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => { const onFilterDropdownOpen = () => {
query.refetch(); query.refetch();
@@ -312,7 +340,8 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
}, },
}; };
}, },
render: (value, record: Obj) => { render: (rawValue, record: Obj) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
const colorStr = props.color(record); const colorStr = props.color(record);
return ( return (
<Row wrap={false} justify="space-around" align="middle"> <Row wrap={false} justify="space-around" align="middle">
@@ -334,3 +363,121 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
}, },
}); });
} }
export function NumberRangeColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
return Column({
...props,
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
return <TextField value={props.defaultText ?? ""} />;
}
if (!Array.isArray(value) || value.length !== 2) {
return <TextField value={props.defaultText ?? ""} />;
}
return (
<NumberFieldUnitRange
value={value}
unit={props.unit}
options={{
maximumFractionDigits: props.maxDecimals ?? 0,
minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0,
}}
/>
);
},
});
}
export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProps<Obj>, "id"> & { field: Field }) {
const field = props.field;
const commonProps = {
...props,
id: ["extra", field.key],
title: field.name,
sorter: false,
transform: (value: unknown) => {
if (value === null || value === undefined) {
return undefined;
}
return JSON.parse(value as string);
},
};
if (field.field_type === FieldType.integer) {
return NumberColumn({
...commonProps,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float) {
return NumberColumn({
...commonProps,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.integer_range) {
return NumberRangeColumn({
...commonProps,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float_range) {
return NumberRangeColumn({
...commonProps,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.text) {
return RichColumn({
...commonProps,
});
} else if (field.field_type === FieldType.datetime) {
return DateColumn({
...commonProps,
});
} else if (field.field_type === FieldType.boolean) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
let text;
if (value === undefined || value === null) {
text = "";
} else if (value) {
text = props.t("yes");
} else {
text = props.t("no");
}
return <TextField value={text} />;
},
});
} else if (field.field_type === FieldType.choice && !field.multi_choice) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={value} />;
},
});
} else if (field.field_type === FieldType.choice && field.multi_choice) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={(value as string[] | undefined)?.join(", ")} />;
},
});
} else {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={value} />;
},
});
}
}

View File

@@ -0,0 +1,33 @@
import { DatePicker } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import advancedFormat from "dayjs/plugin/advancedFormat";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
// Localized date time format with timezone
const dateTimeFormat = "YYYY-MM-DD HH:mm:ss";
export function DateTimePicker<T extends string | dayjs.Dayjs>(props: { value?: T; onChange?: (value?: T) => void }) {
return (
<DatePicker
showTime={{ use12Hours: false }}
format={dateTimeFormat}
value={props.value ? dayjs(props.value) : undefined}
onChange={(value) => {
if (value) {
if (typeof props.value === "string") {
props.onChange?.(value.toISOString() as T);
} else {
props.onChange?.(value as T);
}
} else {
props.onChange?.(undefined);
}
}}
/>
);
}

View File

@@ -0,0 +1,211 @@
import { DateField, TextField } from "@refinedev/antd";
import { Field, FieldType } from "../utils/queryFields";
import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
import { enrichText } from "../utils/parsing";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { Checkbox, Form, Input, InputNumber, Select, Typography } from "antd";
import { FormItemProps, Rule } from "antd/es/form";
import { DateTimePicker } from "./dateTimePicker";
import { InputNumberRange } from "./inputNumberRange";
dayjs.extend(utc);
const { Title } = Typography;
/**
* Title and value to display an extra field. Used for the show pages.
* @param props
* @returns
*/
export function ExtraFieldDisplay(props: { field: Field; value: string | undefined }) {
const { field, value } = props;
let item;
if (value !== undefined) {
const parsedValue = JSON.parse(value);
if (field.field_type === FieldType.integer) {
item = (
<NumberFieldUnit
value={parsedValue ?? ""}
unit={field.unit ?? ""}
options={{
maximumFractionDigits: 0,
minimumFractionDigits: 0,
}}
/>
);
} else if (field.field_type === FieldType.float) {
item = (
<NumberFieldUnit
value={parsedValue ?? ""}
unit={field.unit ?? ""}
options={{
maximumFractionDigits: 3,
minimumFractionDigits: 0,
}}
/>
);
} else if (field.field_type === FieldType.integer_range || field.field_type === FieldType.float_range) {
if (!Array.isArray(parsedValue) || parsedValue.length !== 2) {
return <TextField value={parsedValue} />;
}
item = (
<NumberFieldUnitRange
value={parsedValue}
unit={field.unit ?? ""}
options={{
maximumFractionDigits: field.field_type === FieldType.float_range ? 3 : 0,
minimumFractionDigits: 0,
}}
/>
);
} else if (field.field_type === FieldType.text) {
item = <TextField value={enrichText(parsedValue)} />;
} else if (field.field_type === FieldType.datetime) {
item = (
<DateField
value={dayjs.utc(parsedValue).local()}
title={dayjs.utc(parsedValue).local().format()}
format="YYYY-MM-DD HH:mm:ss"
/>
);
} else if (field.field_type === FieldType.boolean) {
item = <TextField value={parsedValue ? "Yes" : "No"} />;
} else if (field.field_type === FieldType.choice && !field.multi_choice) {
item = <TextField value={parsedValue} />;
} else if (field.field_type === FieldType.choice && field.multi_choice) {
item = <TextField value={parsedValue.join(", ")} />;
} else {
throw new Error(`Unknown field type: ${field.field_type}`);
}
} else {
item = <></>;
}
return (
<>
<Title level={5}>{field.name}</Title>
{item}
</>
);
}
/**
* Form item for an extra field. Used for the edit pages.
* @param props
* @returns
*/
export function ExtraFieldFormItem(props: { field: Field; setDefaultValue?: boolean }) {
const { field } = props;
let inputNode;
const rules: Rule[] = [
{
required: false,
},
];
const formItemProps: FormItemProps = {};
if (field.field_type === FieldType.integer) {
inputNode = <InputNumber addonAfter={field.unit} precision={0} />;
rules.push({
type: "integer",
});
} else if (field.field_type === FieldType.float) {
inputNode = <InputNumber addonAfter={field.unit} precision={3} />;
rules.push({
type: "number",
});
} else if (field.field_type === FieldType.integer_range) {
inputNode = <InputNumberRange unit={field.unit} precision={0} />;
} else if (field.field_type === FieldType.float_range) {
inputNode = <InputNumberRange unit={field.unit} precision={3} />;
} else if (field.field_type === FieldType.text) {
inputNode = <Input />;
rules.push({
type: "string",
});
} else if (field.field_type === FieldType.datetime) {
inputNode = <DateTimePicker />;
} else if (field.field_type === FieldType.boolean) {
inputNode = <Checkbox />;
formItemProps.valuePropName = "checked";
rules.push({
type: "boolean",
});
} else if (field.field_type === FieldType.choice) {
inputNode = (
<Select
mode={field.multi_choice ? "multiple" : undefined}
options={field.choices?.map((choice) => ({ label: choice, value: choice }))}
/>
);
rules.push({
type: field.multi_choice ? "array" : "string",
});
} else {
throw new Error(`Unknown field type: ${field.field_type}`);
}
if (props.setDefaultValue) {
formItemProps.initialValue = field.default_value;
}
return (
<Form.Item label={field.name} name={["extra", field.key]} rules={rules} {...formItemProps}>
{inputNode}
</Form.Item>
);
}
/**
* Convert the string-based value extra key-values of an entity to their JSON-parsed values.
* @param obj
* @returns
*/
export function ParsedExtras<T extends { extra?: { [key: string]: string } }>(
obj: T
): Omit<T, "extra"> & { extra?: { [key: string]: unknown } } {
if (obj.extra) {
const newExtra: { [key: string]: unknown } = {};
Object.entries(obj.extra).forEach(([key, value]) => {
try {
newExtra[key] = JSON.parse(value);
} catch (e) {
newExtra[key] = value;
}
});
return {
...obj,
extra: newExtra,
};
} else {
return obj;
}
}
/**
* Convert the JSON-parsed value extra key-values of an entity to their string values.
* @param obj
* @returns
*/
export function StringifiedExtras<T extends { extra?: { [key: string]: unknown } }>(
obj: T
): Omit<T, "extra"> & { extra?: { [key: string]: string } } {
if (obj.extra) {
const newExtra: { [key: string]: string } = {};
Object.entries(obj.extra).forEach(([key, value]) => {
newExtra[key] = JSON.stringify(value);
});
return {
...obj,
extra: newExtra,
};
} else {
return {
...obj,
extra: undefined,
};
}
}

View File

@@ -0,0 +1,57 @@
import { InputNumber } from "antd";
function parseValue(value?: (number | null)[]): [number | null, number | null] {
if (value === undefined) {
return [null, null];
}
if (!Array.isArray(value) || value.length !== 2) {
return [null, null];
}
const [min, max] = value;
return [min, max];
}
/**
* An Ant Design compatible form input for a number range.
* @param props
* @returns
*/
export function InputNumberRange(props: {
/** Decimal precision */ precision?: number;
/** Unit to display */ unit?: string;
/** Value state */ value?: (number | null)[] | undefined;
/** Called when input changes. Used to update value state. */ onChange?: (value: (number | null)[]) => void;
}) {
const [min, max] = parseValue(props.value);
return (
<>
<InputNumber
value={min}
precision={props.precision ?? 0}
addonAfter={props.unit}
style={{ maxWidth: 110 }}
onChange={(value) => {
if (props.onChange) {
props.onChange([value, max]);
}
}}
/>
{" \u2013 "}
<InputNumber
value={max}
precision={props.precision ?? 0}
addonAfter={props.unit}
style={{ maxWidth: 110 }}
onChange={(value) => {
if (props.onChange) {
props.onChange([min, value]);
}
}}
/>
</>
);
}

View File

@@ -26,3 +26,36 @@ export const NumberFieldUnit: React.FC<Props> = ({ value, locale, options, ...re
</Text> </Text>
); );
}; };
/**
* Like a {@link NumberFieldUnit} but for a range of numbers.
* @param props
* @returns
*/
export function NumberFieldUnitRange(props: {
value: (number | null)[] | undefined;
unit?: string;
options?: Intl.NumberFormatOptions;
}) {
const { value, unit, options } = props;
if (value === undefined) {
console.warn("NumberFieldUnitRange received undefined value");
return <></>;
}
if (!Array.isArray(value) || value.length !== 2) {
console.warn("NumberFieldUnitRange received invalid value", value);
return <></>;
}
const [min, max] = value;
return (
<>
{min === null ? <></> : <NumberFieldUnit value={min} unit={unit ?? ""} options={options} />}
{" \u2013 "}
{max === null ? <></> : <NumberFieldUnit value={max} unit={unit ?? ""} options={options} />}
</>
);
}

View File

@@ -7,6 +7,7 @@ import { useTable } from "@refinedev/antd";
import { t } from "i18next"; import { t } from "i18next";
import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "./otherModels"; import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "./otherModels";
import { removeUndefined } from "../utils/filtering"; import { removeUndefined } from "../utils/filtering";
import { useNavigate } from "react-router-dom";
interface Props { interface Props {
visible: boolean; visible: boolean;
@@ -40,6 +41,7 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
const [selectedItems, setSelectedItems] = useState<number[]>([]); const [selectedItems, setSelectedItems] = useState<number[]>([]);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const navigate = useNavigate();
const { tableProps, sorters, filters, current, pageSize } = useTable<ISpoolCollapsed>({ const { tableProps, sorters, filters, current, pageSize } = useTable<ISpoolCollapsed>({
meta: { meta: {
@@ -102,6 +104,17 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
const isSomeButNotAllFilteredSelected = const isSomeButNotAllFilteredSelected =
dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected; dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected;
const commonProps = {
t,
navigate,
actions: () => {
return [];
},
dataSource,
tableState,
sorter: true,
};
return ( return (
<Modal <Modal
title={t("printing.spoolSelect.title")} title={t("printing.spoolSelect.title")}
@@ -137,26 +150,23 @@ const SpoolSelectModal: React.FC<Props> = ({ visible, description, onCancel, onC
), ),
}, },
SortedColumn({ SortedColumn({
...commonProps,
id: "id", id: "id",
i18ncat: "spool", i18ncat: "spool",
dataSource,
tableState,
width: 80, width: 80,
}), }),
SpoolIconColumn({ SpoolIconColumn({
...commonProps,
id: "combined_name", id: "combined_name",
dataId: "filament.id", dataId: "filament.id",
i18nkey: "spool.fields.filament_name", i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex, color: (record: ISpoolCollapsed) => record.filament.color_hex,
dataSource,
tableState,
filterValueQuery: useSpoolmanFilamentFilter(), filterValueQuery: useSpoolmanFilamentFilter(),
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps,
id: "filament.material", id: "filament.material",
i18nkey: "spool.fields.material", i18nkey: "spool.fields.material",
dataSource,
tableState,
filterValueQuery: useSpoolmanMaterials(), filterValueQuery: useSpoolmanMaterials(),
}), }),
])} ])}

View File

@@ -1,11 +1,17 @@
import React from "react"; import React from "react";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd"; import { Create, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, Select, InputNumber, ColorPicker, Button } from "antd"; import { Form, Input, Select, InputNumber, ColorPicker, Button, Typography } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { numberFormatter, numberParser } from "../../utils/parsing"; import { numberFormatter, numberParser } from "../../utils/parsing";
import { IVendor } from "../vendors/model"; import { IVendor } from "../vendors/model";
import { IFilament } from "./model"; import { IFilament, IFilamentParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
interface CreateOrCloneProps { interface CreateOrCloneProps {
mode: "create" | "clone"; mode: "create" | "clone";
@@ -13,10 +19,20 @@ interface CreateOrCloneProps {
export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => { export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => {
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.filament);
const { form, formProps, formLoading, onFinish, redirect } = useForm<IFilament>(); const { form, formProps, formLoading, onFinish, redirect } = useForm<
IFilament,
HttpError,
IFilamentParsedExtras,
IFilamentParsedExtras
>();
if (props.mode === "clone" && formProps.initialValues) { if (!formProps.initialValues) {
formProps.initialValues = {};
}
if (props.mode === "clone") {
// Fix the vendor_id // Fix the vendor_id
if (formProps.initialValues.vendor) { if (formProps.initialValues.vendor) {
formProps.initialValues.vendor_id = formProps.initialValues.vendor.id; formProps.initialValues.vendor_id = formProps.initialValues.vendor.id;
@@ -24,7 +40,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
} }
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => { const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
const values = await form.validateFields(); const values = StringifiedExtras(await form.validateFields());
await onFinish(values); await onFinish(values);
redirect(redirectTo, (values as IFilament).id); redirect(redirectTo, (values as IFilament).id);
}; };
@@ -34,6 +50,17 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
optionLabel: "name", optionLabel: "name",
}); });
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
React.useEffect(() => {
extraFields.data?.forEach((field) => {
if (formProps.initialValues && field.default_value) {
const parsedValue = JSON.parse(field.default_value as string);
form.setFieldsValue({ extra: { [field.key]: parsedValue } });
}
});
}, [form, extraFields.data, formProps.initialValues]);
return ( return (
<Create <Create
title={props.mode === "create" ? t("filament.titles.create") : t("filament.titles.clone")} title={props.mode === "create" ? t("filament.titles.create") : t("filament.titles.clone")}
@@ -229,6 +256,10 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
> >
<TextArea maxLength={1024} /> <TextArea maxLength={1024} />
</Form.Item> </Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form> </Form>
</Create> </Create>
); );

View File

@@ -1,36 +1,65 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm, useSelect } from "@refinedev/antd"; import { Edit, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber, ColorPicker, message, Alert } from "antd"; import { Form, Input, DatePicker, Select, InputNumber, ColorPicker, message, Alert, Typography } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { numberFormatter, numberParser } from "../../utils/parsing"; import { numberFormatter, numberParser } from "../../utils/parsing";
import { IVendor } from "../vendors/model"; import { IVendor } from "../vendors/model";
import { IFilament } from "./model"; import { IFilament, IFilamentParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import { ParsedExtras } from "../../components/extraFields";
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
in order for Ant design's form to work properly. ParsedExtras does this for us.
We also need to stringify them again before sending them back to the API, which is done by overriding
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
*/
export const FilamentEdit: React.FC<IResourceComponentsProps> = () => { export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false); const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.filament);
const { formProps, saveButtonProps } = useForm<IFilament>({ const { formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilamentParsedExtras, IFilamentParsedExtras>({
liveMode: "manual", liveMode: "manual",
onLiveEvent() { onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened // Warn the user if the filament has been updated since the form was opened
messageApi.warning(t("filament.form.filament_updated")); messageApi.warning(t("filament.form.filament_updated"));
setHasChanged(true); setHasChanged(true);
}, },
queryOptions: {
select(data) {
return {
data: ParsedExtras(data.data),
};
},
},
}); });
// Get vendor selection options
const { selectProps } = useSelect<IVendor>({ const { selectProps } = useSelect<IVendor>({
resource: "vendor", resource: "vendor",
optionLabel: "name", optionLabel: "name",
}); });
// Add the vendor_id field to the form
if (formProps.initialValues) { if (formProps.initialValues) {
formProps.initialValues["vendor_id"] = formProps.initialValues["vendor"]?.id; formProps.initialValues["vendor_id"] = formProps.initialValues["vendor"]?.id;
} }
// Override the form's onFinish method to stringify the extra fields
const originalOnFinish = formProps.onFinish;
formProps.onFinish = (allValues: IFilamentParsedExtras) => {
if (allValues !== undefined && allValues !== null) {
allValues = StringifiedExtras(allValues);
}
originalOnFinish?.(allValues);
};
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
{contextHolder} {contextHolder}
@@ -246,6 +275,10 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
> >
<TextArea maxLength={1024} /> <TextArea maxLength={1024} />
</Form.Item> </Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form> </Form>
{hasChanged && <Alert description={t("filament.form.filament_updated")} type="warning" showIcon />} {hasChanged && <Alert description={t("filament.form.filament_updated")} type="warning" showIcon />}
</Edit> </Edit>

View File

@@ -15,6 +15,7 @@ import {
SortedColumn, SortedColumn,
SpoolIconColumn, SpoolIconColumn,
ActionsColumn, ActionsColumn,
CustomFieldColumn,
} from "../../components/column"; } from "../../components/column";
import { import {
useSpoolmanArticleNumbers, useSpoolmanArticleNumbers,
@@ -24,6 +25,8 @@ import {
} from "../../components/otherModels"; } from "../../components/otherModels";
import { useLiveify } from "../../components/liveify"; import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering"; import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useNavigate } from "react-router-dom";
dayjs.extend(utc); dayjs.extend(utc);
@@ -71,6 +74,10 @@ const defaultColumns = allColumns.filter(
export const FilamentList: React.FC<IResourceComponentsProps> = () => { export const FilamentList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const invalidate = useInvalidate(); const invalidate = useInvalidate();
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.filament);
const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
// Load initial state // Load initial state
const initialState = useInitialTableState(namespace); const initialState = useInitialTableState(namespace);
@@ -145,6 +152,15 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
{ name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("filament", record.id) }, { name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("filament", record.id) },
]; ];
const commonProps = {
t,
navigate,
actions,
dataSource,
tableState,
sorter: true,
};
return ( return (
<List <List
headerButtons={({ defaultButtons }) => ( headerButtons={({ defaultButtons }) => (
@@ -163,10 +179,20 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
<Dropdown <Dropdown
trigger={["click"]} trigger={["click"]}
menu={{ menu={{
items: allColumns.map((column_id) => ({ items: allColumnsWithExtraFields.map((column_id) => {
key: column_id, if (column_id.indexOf("extra.") === 0) {
label: t(translateColumnI18nKey(column_id)), const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id);
})), return {
key: column_id,
label: extraField?.name ?? column_id,
};
}
return {
key: column_id,
label: t(translateColumnI18nKey(column_id)),
};
}),
selectedKeys: showColumns, selectedKeys: showColumns,
selectable: true, selectable: true,
multiple: true, multiple: true,
@@ -195,129 +221,107 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
rowKey="id" rowKey="id"
columns={removeUndefined([ columns={removeUndefined([
SortedColumn({ SortedColumn({
...commonProps,
id: "id", id: "id",
i18ncat: "filament", i18ncat: "filament",
actions,
dataSource,
tableState,
width: 70, width: 70,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps,
id: "vendor.name", id: "vendor.name",
i18nkey: "filament.fields.vendor_name", i18nkey: "filament.fields.vendor_name",
actions,
dataSource,
tableState,
filterValueQuery: useSpoolmanVendors(), filterValueQuery: useSpoolmanVendors(),
}), }),
SpoolIconColumn({ SpoolIconColumn({
...commonProps,
id: "name", id: "name",
i18ncat: "filament", i18ncat: "filament",
color: (record: IFilamentCollapsed) => record.color_hex, color: (record: IFilamentCollapsed) => record.color_hex,
actions,
dataSource,
tableState,
filterValueQuery: useSpoolmanFilamentNames(), filterValueQuery: useSpoolmanFilamentNames(),
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps,
id: "material", id: "material",
i18ncat: "filament", i18ncat: "filament",
actions,
dataSource,
tableState,
filterValueQuery: useSpoolmanMaterials(), filterValueQuery: useSpoolmanMaterials(),
width: 110, width: 110,
}), }),
SortedColumn({ SortedColumn({
...commonProps,
id: "price", id: "price",
i18ncat: "filament", i18ncat: "filament",
actions,
dataSource,
tableState,
width: 80, width: 80,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "density", id: "density",
i18ncat: "filament", i18ncat: "filament",
unit: "g/cm³", unit: "g/cm³",
decimals: 2, maxDecimals: 2,
actions,
dataSource,
tableState,
width: 100, width: 100,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "diameter", id: "diameter",
i18ncat: "filament", i18ncat: "filament",
unit: "mm", unit: "mm",
decimals: 2, maxDecimals: 2,
actions,
dataSource,
tableState,
width: 100, width: 100,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "weight", id: "weight",
i18ncat: "filament", i18ncat: "filament",
unit: "g", unit: "g",
decimals: 1, maxDecimals: 1,
actions,
dataSource,
tableState,
width: 100, width: 100,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "spool_weight", id: "spool_weight",
i18ncat: "filament", i18ncat: "filament",
unit: "g", unit: "g",
decimals: 1, maxDecimals: 1,
actions,
dataSource,
tableState,
width: 100, width: 100,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps,
id: "article_number", id: "article_number",
i18ncat: "filament", i18ncat: "filament",
actions,
dataSource,
tableState,
filterValueQuery: useSpoolmanArticleNumbers(), filterValueQuery: useSpoolmanArticleNumbers(),
width: 130, width: 130,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "settings_extruder_temp", id: "settings_extruder_temp",
i18ncat: "filament", i18ncat: "filament",
unit: "°C", unit: "°C",
decimals: 0, maxDecimals: 0,
actions,
dataSource,
tableState,
width: 100, width: 100,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "settings_bed_temp", id: "settings_bed_temp",
i18ncat: "filament", i18ncat: "filament",
unit: "°C", unit: "°C",
decimals: 0, maxDecimals: 0,
actions,
dataSource,
tableState,
width: 100, width: 100,
}), }),
DateColumn({ DateColumn({
...commonProps,
id: "registered", id: "registered",
i18ncat: "filament", i18ncat: "filament",
actions,
dataSource,
tableState,
}), }),
...(extraFields.data?.map((field) => {
return CustomFieldColumn({
...commonProps,
field,
});
}) ?? []),
RichColumn({ RichColumn({
...commonProps,
id: "comment", id: "comment",
i18ncat: "filament", i18ncat: "filament",
actions,
dataSource,
tableState,
width: 150, width: 150,
}), }),
ActionsColumn(actions), ActionsColumn(actions),

View File

@@ -16,4 +16,8 @@ export interface IFilament {
settings_extruder_temp?: number; settings_extruder_temp?: number;
settings_bed_temp?: number; settings_bed_temp?: number;
color_hex?: string; color_hex?: string;
extra: { [key: string]: string };
} }
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
export type IFilamentParsedExtras = Omit<IFilament, "extra"> & { extra?: { [key: string]: unknown } };

View File

@@ -1,13 +1,15 @@
import React from "react"; import React from "react";
import { IResourceComponentsProps, useShow, useTranslate } from "@refinedev/core"; import { IResourceComponentsProps, useShow, useTranslate } from "@refinedev/core";
import { Show, NumberField, DateField, TextField } from "@refinedev/antd"; import { Show, NumberField, DateField, TextField } from "@refinedev/antd";
import { Typography } from "antd"; import { Button, Typography } from "antd";
import { NumberFieldUnit } from "../../components/numberField"; import { NumberFieldUnit } from "../../components/numberField";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { IFilament } from "./model"; import { IFilament } from "./model";
import { enrichText } from "../../utils/parsing"; import { enrichText } from "../../utils/parsing";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldDisplay } from "../../components/extraFields";
dayjs.extend(utc); dayjs.extend(utc);
const { Title } = Typography; const { Title } = Typography;
@@ -15,6 +17,7 @@ const { Title } = Typography;
export const FilamentShow: React.FC<IResourceComponentsProps> = () => { export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const navigate = useNavigate(); const navigate = useNavigate();
const extraFields = useGetFields(EntityType.filament);
const { queryResult } = useShow<IFilament>({ const { queryResult } = useShow<IFilament>({
liveMode: "auto", liveMode: "auto",
}); });
@@ -45,7 +48,18 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
}; };
return ( return (
<Show isLoading={isLoading} title={record ? formatTitle(record) : ""}> <Show
isLoading={isLoading}
title={record ? formatTitle(record) : ""}
headerButtons={({ defaultButtons }) => (
<>
<Button type="primary" onClick={gotoSpools}>
{t("filament.fields.spools")}
</Button>
{defaultButtons}
</>
)}
>
<Title level={5}>{t("filament.fields.id")}</Title> <Title level={5}>{t("filament.fields.id")}</Title>
<NumberField value={record?.id ?? ""} /> <NumberField value={record?.id ?? ""} />
<Title level={5}>{t("filament.fields.vendor")}</Title> <Title level={5}>{t("filament.fields.vendor")}</Title>
@@ -63,8 +77,6 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
/> />
<Title level={5}>{t("filament.fields.name")}</Title> <Title level={5}>{t("filament.fields.name")}</Title>
<TextField value={record?.name} /> <TextField value={record?.name} />
{/* <Title level={5}>{t("filament.fields.id")}</Title>
{vendorIsLoading ? <>Loading...</> : <>{vendorData?.data?.id}</>} */}
<Title level={5}>{t("filament.fields.color_hex")}</Title> <Title level={5}>{t("filament.fields.color_hex")}</Title>
<TextField value={record?.color_hex} /> <TextField value={record?.color_hex} />
<Title level={5}>{t("filament.fields.material")}</Title> <Title level={5}>{t("filament.fields.material")}</Title>
@@ -123,13 +135,10 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
<TextField value={record?.article_number} /> <TextField value={record?.article_number} />
<Title level={5}>{t("filament.fields.comment")}</Title> <Title level={5}>{t("filament.fields.comment")}</Title>
<TextField value={enrichText(record?.comment)} /> <TextField value={enrichText(record?.comment)} />
<Title level={5}>{t("filament.fields.spools")}</Title> <Title level={4}>{t("settings.extra_fields.tab")}</Title>
<button {extraFields?.data?.map((field, index) => (
onClick={gotoSpools} <ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
style={{ background: "none", border: "none", color: "blue", cursor: "pointer", paddingLeft: 0 }} ))}
>
{record ? formatTitle(record) : ""}
</button>
</Show> </Show>
); );
}; };

View File

@@ -0,0 +1,636 @@
import {
Button,
Checkbox,
Flex,
Form,
FormInstance,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Table,
message,
} from "antd";
import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields";
import { useTranslate } from "@refinedev/core";
import { useState } from "react";
import { ColumnType } from "antd/es/table";
import { Trans } from "react-i18next";
import { useParams } from "react-router-dom";
import { FormItemProps, Rule } from "antd/es/form";
import { PlusOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import advancedFormat from "dayjs/plugin/advancedFormat";
import { DateTimePicker } from "../../components/dateTimePicker";
import { InputNumberRange } from "../../components/inputNumberRange";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
// Localized date time format with timezone
const dateTimeFormat = "YYYY-MM-DD HH:mm:ss";
interface EditableCellProps extends React.HTMLAttributes<HTMLElement> {
record: FieldHolder;
editing: boolean;
dataIndex: string;
form: FormInstance;
children: React.ReactNode;
}
interface FieldHolder {
key: string;
field: Field;
is_new: boolean;
}
const canEditField = (dataIndex: string, isNew: boolean) => {
if (isNew) {
return true;
}
return dataIndex !== "key" && dataIndex !== "field_type" && dataIndex !== "multi_choice";
};
const EditableCell: React.FC<EditableCellProps> = ({ record, editing, dataIndex, children, form, ...restProps }) => {
const t = useTranslate();
if (!editing || !canEditField(dataIndex, record.is_new)) {
return (
<td
{...restProps}
style={{
wordBreak: "break-word",
}}
>
{children}
</td>
);
}
const fieldType = form.getFieldValue("field_type") as FieldType;
const choices = form.getFieldValue("choices") as string[];
const title = t(`settings.extra_fields.params.${dataIndex}`);
let inputNode;
const rules: Rule[] = [];
const formItemProps: FormItemProps = {};
if (dataIndex === "key") {
inputNode = <Input />;
rules.push({
required: true,
min: 1,
max: 64,
pattern: /^[a-z0-9_]+$/,
});
rules.push({
validator: async (_, value) => {
// Ensure key is not new_field
if (value === "new_field") {
throw new Error(t("settings.extra_fields.key_not_changed"));
}
},
});
} else if (dataIndex === "field_type") {
inputNode = (
<Select
options={[
{
label: t(`settings.extra_fields.field_type.${FieldType.text}`),
value: FieldType.text,
},
{
label: t(`settings.extra_fields.field_type.${FieldType.integer}`),
value: FieldType.integer,
},
{
label: t(`settings.extra_fields.field_type.${FieldType.integer_range}`),
value: FieldType.integer_range,
},
{
label: t(`settings.extra_fields.field_type.${FieldType.float}`),
value: FieldType.float,
},
{
label: t(`settings.extra_fields.field_type.${FieldType.float_range}`),
value: FieldType.float_range,
},
{
label: t(`settings.extra_fields.field_type.${FieldType.datetime}`),
value: FieldType.datetime,
},
{
label: t(`settings.extra_fields.field_type.${FieldType.boolean}`),
value: FieldType.boolean,
},
{
label: t(`settings.extra_fields.field_type.${FieldType.choice}`),
value: FieldType.choice,
},
]}
onChange={() => {
// Reset default_value when changing field_type
form.setFieldsValue({
default_value: undefined,
});
}}
/>
);
rules.push({
required: true,
});
} else if (dataIndex === "name") {
inputNode = <Input />;
rules.push({
required: true,
min: 1,
max: 128,
});
} else if (dataIndex === "order") {
inputNode = <InputNumber style={{ width: "60px" }} />;
rules.push({
required: true,
min: 0,
type: "integer",
});
} else if (dataIndex === "unit") {
if (
fieldType === FieldType.integer ||
fieldType === FieldType.integer_range ||
fieldType === FieldType.float ||
fieldType === FieldType.float_range
) {
inputNode = <Input style={{ width: "60px" }} />;
} else {
inputNode = null;
}
rules.push({
required: false,
max: 16,
});
} else if (dataIndex === "default_value") {
if (fieldType === FieldType.boolean) {
inputNode = <Checkbox />;
formItemProps.valuePropName = "checked";
rules.push({
type: "boolean",
});
} else if (fieldType === FieldType.text) {
inputNode = <Input />;
rules.push({
type: "string",
});
} else if (fieldType === FieldType.integer) {
inputNode = <InputNumber />;
rules.push({
type: "integer",
});
} else if (fieldType === FieldType.float) {
inputNode = <InputNumber />;
rules.push({
type: "number",
});
} else if (fieldType === FieldType.integer_range) {
inputNode = <InputNumberRange precision={0} />;
rules.push({
type: "array",
len: 2,
});
} else if (fieldType === FieldType.float_range) {
inputNode = <InputNumberRange precision={3} />;
rules.push({
type: "array",
len: 2,
});
} else if (fieldType === FieldType.datetime) {
inputNode = <DateTimePicker />;
} else if (fieldType === FieldType.choice) {
inputNode = (
<Select
mode={form.getFieldValue("multi_choice") ? "multiple" : undefined}
options={choices.map((choice) => ({ label: choice, value: choice }))}
/>
);
rules.push({
type: form.getFieldValue("multi_choice") ? "array" : "string",
});
}
} else if (dataIndex === "choices") {
if (fieldType === FieldType.choice) {
inputNode = <Select mode="tags" tokenSeparators={[","]} open={false} />;
rules.push({
required: true,
min: 1,
type: "array",
validator: async (_, value) => {
// Verify that all values in record.choices are in value
const recordChoices = record.field.choices || [];
const valueChoices = value || [];
const missingChoices = recordChoices.filter((choice) => !valueChoices.includes(choice));
if (missingChoices.length > 0) {
throw new Error(
t("settings.extra_fields.choices_missing_error", {
choices: missingChoices.join(", "),
})
);
}
},
});
} else {
inputNode = null;
}
} else if (dataIndex === "multi_choice") {
if (fieldType === FieldType.choice) {
inputNode = (
<Checkbox
onChange={() => {
// Reset default_value when changing multi_choice
form.setFieldsValue({
default_value: undefined,
});
}}
>
{title}
</Checkbox>
);
formItemProps.valuePropName = "checked";
rules.push({
type: "boolean",
});
} else {
inputNode = null;
}
} else {
inputNode = null;
}
const formItem = inputNode ? (
<Form.Item
name={dataIndex}
messageVariables={{ label: title }}
style={{ margin: 0 }}
rules={rules}
{...formItemProps}
>
{inputNode}
</Form.Item>
) : null;
return <td {...restProps}>{formItem}</td>;
};
export function ExtraFieldsSettings() {
const { entityType } = useParams<{ entityType: EntityType }>();
const t = useTranslate();
const [form] = Form.useForm();
const fields = useGetFields(entityType as EntityType);
const setField = useSetField(entityType as EntityType);
const deleteField = useDeleteField(entityType as EntityType);
const [isSubmitting, setIsSubmitting] = useState(false);
const [newField, setNewField] = useState<FieldHolder | null>(null);
const [messageApi, contextHolder] = message.useMessage();
const [editingKey, setEditingKey] = useState("");
const isEditing = (record: FieldHolder) => record.field.key === editingKey;
const edit = (record: Partial<Field> & { key: React.Key }) => {
const values = { ...record };
if (values.default_value && typeof values.default_value === "string") {
const def = JSON.parse(values.default_value);
if (values.field_type === FieldType.boolean) {
values.default_value = def ? true : false;
} else {
values.default_value = def;
}
} else {
values.default_value = undefined;
}
form.setFieldsValue(values);
setEditingKey(record.key);
};
const cancel = () => {
setEditingKey("");
setNewField(null);
};
const del = async (field: Field) => {
try {
await deleteField.mutateAsync(field.key);
} catch (errInfo) {
if (errInfo instanceof Error) {
messageApi.error(errInfo.message);
}
}
};
const addNewField = () => {
// Calculate new order by getting the highest order and adding 1
const newOrder = Math.max(...(fields.data?.map((field) => field.order) || []), 0) + 1;
const newFieldData: Field = {
key: "new_field",
name: "",
entity_type: entityType as EntityType,
field_type: FieldType.text,
unit: "",
order: newOrder,
default_value: "",
choices: [],
multi_choice: false,
};
setNewField({
key: "new_field",
field: newFieldData,
is_new: true,
});
form.setFieldsValue(newFieldData);
setEditingKey("new_field");
};
const save = async (record: FieldHolder) => {
let row;
try {
row = (await form.validateFields()) as Field;
} catch (errInfo) {
// Ignore these errors because they are already handled by the form
return;
}
const updatedField = {
...record.field,
...row,
};
// Do some value conversions
try {
// Convert float and integer range to array using the validation regex
if (
(updatedField.field_type === FieldType.float_range || updatedField.field_type === FieldType.integer_range) &&
updatedField.default_value !== undefined
) {
if (Array.isArray(updatedField.default_value)) {
const val1 = updatedField.default_value[0];
const val2 = updatedField.default_value[1];
updatedField.default_value = JSON.stringify([val1, val2]);
} else {
console.warn("Invalid default_value for range", updatedField.default_value);
}
} else {
// Just stringify all other values
updatedField.default_value = JSON.stringify(updatedField.default_value);
}
// Set multi_choice if it's not set and field_type is choice
if (updatedField.field_type === FieldType.choice && updatedField.multi_choice === undefined) {
updatedField.multi_choice = false;
}
// If it's not choice, remove choices and multi_choice
if (updatedField.field_type !== FieldType.choice) {
updatedField.choices = undefined;
updatedField.multi_choice = undefined;
}
// If unit is an empty string, set it to null instead
if (updatedField.unit === "") {
updatedField.unit = undefined;
}
} catch (errInfo) {
if (errInfo instanceof Error) {
messageApi.error(t("notifications.validationError", { error: errInfo.message }));
// Check if errInfo has the errorFields property, then we should skip the error message
}
return;
}
// Validate that updatedField.key is unique and not among the other keys
if (record.is_new) {
const keys = new Set(fields.data?.map((field) => field.key) || []);
if (keys.has(updatedField.key)) {
messageApi.error(t("settings.extra_fields.non_unique_key_error"));
return;
}
}
// Submit it!
try {
setIsSubmitting(true);
setField.reset();
await setField.mutateAsync({
key: updatedField.key,
params: updatedField,
});
setEditingKey("");
setNewField(null);
} catch (errInfo) {
if (errInfo instanceof Error) {
messageApi.error(errInfo.message);
}
}
setIsSubmitting(false);
};
const niceName = t(`${entityType}.${entityType}`);
const columns: ColumnType<FieldHolder>[] = [
{
title: t("settings.extra_fields.params.key"),
dataIndex: ["field", "key"],
key: "key",
width: "10%",
},
{
title: t("settings.extra_fields.params.order"),
dataIndex: ["field", "order"],
key: "order",
width: "3%",
},
{
title: t("settings.extra_fields.params.name"),
dataIndex: ["field", "name"],
},
{
title: t("settings.extra_fields.params.field_type"),
dataIndex: ["field", "field_type"],
render(value) {
return t(`settings.extra_fields.field_type.${value}`);
},
width: "15%",
},
{
title: t("settings.extra_fields.params.unit"),
dataIndex: ["field", "unit"],
width: "6%",
},
{
title: t("settings.extra_fields.params.default_value"),
dataIndex: ["field", "default_value"],
render(value, record) {
const val = JSON.parse(value || "null");
if (typeof val === "boolean") {
return val ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false");
} else if (typeof val === "string" && record.field.field_type === FieldType.datetime) {
return dayjs(val).format(dateTimeFormat);
} else if (typeof val === "number" || typeof val === "string") {
return val;
} else if (Array.isArray(val) && record.field.field_type === FieldType.choice) {
return val.join(", ");
} else if (
(Array.isArray(val) && record.field.field_type === FieldType.integer_range) ||
record.field.field_type === FieldType.float_range
) {
return `${val[0]} \u2013 ${val[1]}`;
} else {
return null;
}
},
width: "15%",
},
{
title: t("settings.extra_fields.params.choices"),
dataIndex: ["field", "choices"],
render(value, record) {
if (record.field.field_type === FieldType.choice && Array.isArray(value)) {
return value.join(", ");
} else {
return null;
}
},
width: "15%",
},
{
title: t("settings.extra_fields.params.multi_choice"),
dataIndex: ["field", "multi_choice"],
render(value, record) {
if (record.field.field_type === FieldType.choice) {
return value ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false");
} else {
return null;
}
},
width: "10%",
},
{
title: "",
dataIndex: "operation",
render: (_: unknown, record: FieldHolder) => {
const editing = isEditing(record);
return editing ? (
<Space>
<Button onClick={() => save(record)} size="small" type="primary">
{t("buttons.save")}
</Button>
<Button onClick={() => cancel()} size="small">
{t("buttons.cancel")}
</Button>
</Space>
) : (
<>
<Space>
<Button disabled={editingKey !== ""} onClick={() => edit(record.field)} size="small">
{t("buttons.edit")}
</Button>
<Popconfirm
title={t("settings.extra_fields.delete_confirm", { name: record.field.name })}
description={t("settings.extra_fields.delete_confirm_description", { name: record.field.name })}
onConfirm={() => del(record.field)}
disabled={editingKey !== ""}
okText={t("buttons.delete")}
cancelText={t("buttons.cancel")}
>
<Button disabled={editingKey !== ""} danger size="small">
{t("buttons.delete")}
</Button>
</Popconfirm>
</Space>
</>
);
},
width: "10%",
},
];
const mergedColumns = columns.map((col) => {
if (col.dataIndex === "operation") {
return col;
}
return {
...col,
onCell: function (record: FieldHolder): EditableCellProps {
return {
record,
editing: isEditing(record),
dataIndex: (col.dataIndex?.toString() || "").split(",").pop() || "",
form: form,
children: [],
};
},
};
});
const tableFields: FieldHolder[] = [
...(fields.data || [])
.sort((a, b) => a.order - b.order)
.map((field) => ({
key: field.key,
field,
is_new: false,
})),
...(newField ? [newField] : []),
];
return (
<>
<h3>
{t("settings.extra_fields.tab")} - {niceName}
</h3>
<Trans
i18nKey={"settings.extra_fields.description"}
components={{
p: <p />,
}}
/>
<Form form={form} component={false} disabled={isSubmitting}>
<Table
components={{
body: {
cell: EditableCell,
},
}}
columns={mergedColumns}
dataSource={tableFields}
loading={fields.isLoading}
rowClassName="editable-row"
pagination={false}
/>
</Form>
{newField == null && (
<Flex justify="center">
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
size="large"
style={{
margin: "1em",
}}
onClick={() => addNewField()}
/>
</Flex>
)}
{contextHolder}
</>
);
}

View File

@@ -0,0 +1,79 @@
import React from "react";
import { useTranslate } from "@refinedev/core";
import { Button, Form, Input, message } from "antd";
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
export function GeneralSettings() {
const settings = useGetSettings();
const setSetting = useSetSetting();
const [form] = Form.useForm();
const [messageApi, contextHolder] = message.useMessage();
const t = useTranslate();
// Set initial form values
React.useEffect(() => {
if (settings.data) {
form.setFieldsValue({
currency: JSON.parse(settings.data.currency.value),
});
}
}, [settings.data, form]);
// Popup message if setSetting is successful
React.useEffect(() => {
if (setSetting.isSuccess) {
messageApi.success(t("notifications.saveSuccessful"));
}
}, [setSetting.isSuccess, messageApi, t]);
// Handle form submit
const onFinish = (values: { currency: string }) => {
// Check if the currency has changed
if (settings.data?.currency.value !== JSON.stringify(values.currency)) {
setSetting.mutate({
key: "currency",
value: JSON.stringify(values.currency),
});
}
};
return (
<>
<Form
form={form}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
initialValues={{
currency: settings.data?.currency.value,
}}
onFinish={onFinish}
style={{
maxWidth: "600px",
margin: "0 auto",
}}
>
<Form.Item
label={t("settings.general.currency.label")}
name="currency"
rules={[
{
required: true,
},
{
pattern: /^[A-Z]{3}$/,
},
]}
>
<Input />
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button type="primary" htmlType="submit" loading={settings.isFetching || setSetting.isLoading}>
{t("buttons.save")}
</Button>
</Form.Item>
</Form>
{contextHolder}
</>
);
}

View File

@@ -0,0 +1,99 @@
import React from "react";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { Content } from "antd/es/layout/layout";
import { Menu, theme } from "antd";
import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
import { GeneralSettings } from "./generalSettings";
import { ExtraFieldsSettings } from "./extraFieldsSettings";
import { Route, Routes, useNavigate } from "react-router-dom";
dayjs.extend(utc);
const { useToken } = theme;
export const Settings: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const navigate = useNavigate();
const getCurrentKey = () => {
const path = window.location.pathname.replace("/settings", "");
// Remove starting slash and ending slash if exists and return
return path.replace(/^\/|\/$/g, "");
};
return (
<>
<h1
style={{
color: token.colorText,
}}
>
{t("settings.header")}
</h1>
<Content
style={{
padding: "1em",
minHeight: 280,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
color: token.colorText,
fontFamily: token.fontFamily,
fontSize: token.fontSizeLG,
lineHeight: 1.5,
}}
>
<Menu
mode="horizontal"
selectedKeys={[getCurrentKey()]}
onClick={(e) => {
if (e.key === "") {
return navigate("/settings");
} else {
return navigate(`/settings/${e.key}`);
}
}}
items={[
{ key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> },
{
key: "extra",
label: t("settings.extra_fields.tab"),
icon: <SolutionOutlined />,
children: [
{
label: t("spool.spool"),
key: "extra/spool",
icon: <FileOutlined />,
},
{
label: t("filament.filament"),
key: "extra/filament",
icon: <HighlightOutlined />,
},
{
label: t("vendor.vendor"),
key: "extra/vendor",
icon: <UserOutlined />,
},
],
},
]}
style={{
marginBottom: "1em",
}}
/>
<main>
<Routes>
<Route index element={<GeneralSettings />} />
<Route path="/extra/:entityType" element={<ExtraFieldsSettings />} />
</Routes>
</main>
</Content>
</>
);
};
export default Settings;

View File

@@ -1,15 +1,20 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd"; import { Create, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Button } from "antd"; import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Button, Typography } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model"; import { IFilament } from "../filaments/model";
import { ISpool } from "./model"; import { ISpool, ISpoolParsedExtras } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing"; import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels"; import { useSpoolmanLocations } from "../../components/otherModels";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"; import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import "../../utils/overrides.css"; import "../../utils/overrides.css";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
interface CreateOrCloneProps { interface CreateOrCloneProps {
mode: "create" | "clone"; mode: "create" | "clone";
@@ -17,13 +22,22 @@ interface CreateOrCloneProps {
export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => { export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => {
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
const { form, formProps, formLoading, onFinish, redirect } = useForm<ISpool>({ const { form, formProps, formLoading, onFinish, redirect } = useForm<
ISpool,
HttpError,
ISpoolParsedExtras,
ISpoolParsedExtras
>({
redirect: false, redirect: false,
warnWhenUnsavedChanges: false, warnWhenUnsavedChanges: false,
}); });
if (!formProps.initialValues) {
formProps.initialValues = {};
}
if (props.mode === "clone" && formProps.initialValues) { if (props.mode === "clone") {
// Clear out the values that we don't want to clone // Clear out the values that we don't want to clone
formProps.initialValues.first_used = null; formProps.initialValues.first_used = null;
formProps.initialValues.last_used = null; formProps.initialValues.last_used = null;
@@ -34,7 +48,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
} }
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => { const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
const values = await form.validateFields(); const values = StringifiedExtras(await form.validateFields());
if (quantity > 1) { if (quantity > 1) {
const submit = Array(quantity).fill(values); const submit = Array(quantity).fill(values);
// queue multiple creates this way for now Refine doesn't seem to map Arrays to createMany or multiple creates like it says it does // queue multiple creates this way for now Refine doesn't seem to map Arrays to createMany or multiple creates like it says it does
@@ -49,6 +63,17 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
resource: "filament", resource: "filament",
}); });
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
React.useEffect(() => {
extraFields.data?.forEach((field) => {
if (formProps.initialValues && field.default_value) {
const parsedValue = JSON.parse(field.default_value as string);
form.setFieldsValue({ extra: { [field.key]: parsedValue } });
}
});
}, [form, extraFields.data, formProps.initialValues]);
const filamentOptions = queryResult.data?.data.map((item) => { const filamentOptions = queryResult.data?.data.map((item) => {
let vendorPrefix = ""; let vendorPrefix = "";
if (item.vendor) { if (item.vendor) {
@@ -213,7 +238,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
}, },
]} ]}
> >
<InputNumber precision={2} formatter={numberFormatter} parser={numberParser} /> <InputNumber precision={2} formatter={numberFormatter} parser={numberParser} />
</Form.Item> </Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}> <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} /> <InputNumber value={usedWeight} />
@@ -336,6 +361,10 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
> >
<TextArea maxLength={1024} /> <TextArea maxLength={1024} />
</Form.Item> </Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form> </Form>
</Create> </Create>
); );

View File

@@ -1,33 +1,61 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm, useSelect } from "@refinedev/antd"; import { Edit, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert } from "antd"; import { Form, Input, DatePicker, Select, InputNumber, Radio, Divider, Alert, Typography } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { IFilament } from "../filaments/model"; import { IFilament } from "../filaments/model";
import { ISpool } from "./model"; import { ISpool, ISpoolParsedExtras } from "./model";
import { numberFormatter, numberParser } from "../../utils/parsing"; import { numberFormatter, numberParser } from "../../utils/parsing";
import { useSpoolmanLocations } from "../../components/otherModels"; import { useSpoolmanLocations } from "../../components/otherModels";
import { message } from "antd/lib"; import { message } from "antd/lib";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import { ParsedExtras } from "../../components/extraFields";
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
in order for Ant design's form to work properly. ParsedExtras does this for us.
We also need to stringify them again before sending them back to the API, which is done by overriding
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
*/
export const SpoolEdit: React.FC<IResourceComponentsProps> = () => { export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false); const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.spool);
const { form, formProps, saveButtonProps } = useForm<ISpool>({ const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolParsedExtras, ISpoolParsedExtras>({
liveMode: "manual", liveMode: "manual",
onLiveEvent() { onLiveEvent() {
// Warn the user if the spool has been updated since the form was opened // Warn the user if the spool has been updated since the form was opened
messageApi.warning(t("spool.form.spool_updated")); messageApi.warning(t("spool.form.spool_updated"));
setHasChanged(true); setHasChanged(true);
}, },
queryOptions: {
select(data) {
return {
data: ParsedExtras(data.data),
};
},
},
}); });
// Get filament selection options
const { queryResult } = useSelect<IFilament>({ const { queryResult } = useSelect<IFilament>({
resource: "filament", resource: "filament",
}); });
// Override the form's onFinish method to stringify the extra fields
const originalOnFinish = formProps.onFinish;
formProps.onFinish = (allValues: ISpoolParsedExtras) => {
if (allValues !== undefined && allValues !== null) {
allValues = StringifiedExtras(allValues);
}
originalOnFinish?.(allValues);
};
const filamentOptions = queryResult.data?.data.map((item) => { const filamentOptions = queryResult.data?.data.map((item) => {
let vendorPrefix = ""; let vendorPrefix = "";
if (item.vendor) { if (item.vendor) {
@@ -306,6 +334,10 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
> >
<TextArea maxLength={1024} /> <TextArea maxLength={1024} />
</Form.Item> </Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form> </Form>
{hasChanged && <Alert description={t("spool.form.spool_updated")} type="warning" showIcon />} {hasChanged && <Alert description={t("spool.form.spool_updated")} type="warning" showIcon />}
</Edit> </Edit>

View File

@@ -23,6 +23,7 @@ import {
SpoolIconColumn, SpoolIconColumn,
Action, Action,
ActionsColumn, ActionsColumn,
CustomFieldColumn,
} from "../../components/column"; } from "../../components/column";
import { setSpoolArchived } from "./functions"; import { setSpoolArchived } from "./functions";
import SelectAndPrint from "../../components/selectAndPrintDialog"; import SelectAndPrint from "../../components/selectAndPrintDialog";
@@ -34,6 +35,8 @@ import {
} from "../../components/otherModels"; } from "../../components/otherModels";
import { useLiveify } from "../../components/liveify"; import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering"; import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useNavigate } from "react-router-dom";
dayjs.extend(utc); dayjs.extend(utc);
@@ -52,7 +55,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
} else { } else {
filament_name = element.filament.name ?? element.filament.id.toString(); filament_name = element.filament.name ?? element.filament.id.toString();
} }
if (!element.price){ if (!element.price) {
element.price = element.filament.price; element.price = element.filament.price;
} }
return { return {
@@ -95,6 +98,10 @@ const defaultColumns = allColumns.filter(
export const SpoolList: React.FC<IResourceComponentsProps> = () => { export const SpoolList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const invalidate = useInvalidate(); const invalidate = useInvalidate();
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.spool);
const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
// Load initial state // Load initial state
const initialState = useInitialTableState(namespace); const initialState = useInitialTableState(namespace);
@@ -217,6 +224,15 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
return actions; return actions;
}; };
const commonProps = {
t,
navigate,
actions,
dataSource,
tableState,
sorter: true,
};
return ( return (
<List <List
headerButtons={({ defaultButtons }) => ( headerButtons={({ defaultButtons }) => (
@@ -245,10 +261,20 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
<Dropdown <Dropdown
trigger={["click"]} trigger={["click"]}
menu={{ menu={{
items: allColumns.map((column_id) => ({ items: allColumnsWithExtraFields.map((column_id) => {
key: column_id, if (column_id.indexOf("extra.") === 0) {
label: t(translateColumnI18nKey(column_id)), const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id);
})), return {
key: column_id,
label: extraField?.name ?? column_id,
};
}
return {
key: column_id,
label: t(translateColumnI18nKey(column_id)),
};
}),
selectedKeys: showColumns, selectedKeys: showColumns,
selectable: true, selectable: true,
multiple: true, multiple: true,
@@ -290,127 +316,105 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
}} }}
columns={removeUndefined([ columns={removeUndefined([
SortedColumn({ SortedColumn({
...commonProps,
id: "id", id: "id",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
width: 70, width: 70,
}), }),
SpoolIconColumn({ SpoolIconColumn({
...commonProps,
id: "combined_name", id: "combined_name",
i18nkey: "spool.fields.filament_name", i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => record.filament.color_hex, color: (record: ISpoolCollapsed) => record.filament.color_hex,
actions,
dataSource,
tableState,
dataId: "filament.id", dataId: "filament.id",
filterValueQuery: useSpoolmanFilamentFilter(), filterValueQuery: useSpoolmanFilamentFilter(),
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps,
id: "filament.material", id: "filament.material",
i18nkey: "spool.fields.material", i18nkey: "spool.fields.material",
actions,
dataSource,
tableState,
filterValueQuery: useSpoolmanMaterials(), filterValueQuery: useSpoolmanMaterials(),
width: 120, width: 120,
}), }),
SortedColumn({ SortedColumn({
...commonProps,
id: "price", id: "price",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
width: 80, width: 80,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "used_weight", id: "used_weight",
i18ncat: "spool", i18ncat: "spool",
unit: "g", unit: "g",
decimals: 1, maxDecimals: 1,
actions,
dataSource,
tableState,
width: 110, width: 110,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "remaining_weight", id: "remaining_weight",
i18ncat: "spool", i18ncat: "spool",
unit: "g", unit: "g",
decimals: 1, maxDecimals: 1,
defaultText: t("unknown"), defaultText: t("unknown"),
actions,
dataSource,
tableState,
width: 110, width: 110,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "used_length", id: "used_length",
i18ncat: "spool", i18ncat: "spool",
unit: "mm", unit: "mm",
decimals: 1, maxDecimals: 1,
actions,
dataSource,
tableState,
width: 120, width: 120,
}), }),
NumberColumn({ NumberColumn({
...commonProps,
id: "remaining_length", id: "remaining_length",
i18ncat: "spool", i18ncat: "spool",
unit: "mm", unit: "mm",
decimals: 1, maxDecimals: 1,
defaultText: t("unknown"), defaultText: t("unknown"),
actions,
dataSource,
tableState,
width: 120, width: 120,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps,
id: "location", id: "location",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
filterValueQuery: useSpoolmanLocations(), filterValueQuery: useSpoolmanLocations(),
width: 120, width: 120,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps,
id: "lot_nr", id: "lot_nr",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
filterValueQuery: useSpoolmanLotNumbers(), filterValueQuery: useSpoolmanLotNumbers(),
width: 120, width: 120,
}), }),
DateColumn({ DateColumn({
...commonProps,
id: "first_used", id: "first_used",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
}), }),
DateColumn({ DateColumn({
...commonProps,
id: "last_used", id: "last_used",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
}), }),
DateColumn({ DateColumn({
...commonProps,
id: "registered", id: "registered",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
}), }),
...(extraFields.data?.map((field) => {
return CustomFieldColumn({
...commonProps,
field,
});
}) ?? []),
RichColumn({ RichColumn({
...commonProps,
id: "comment", id: "comment",
i18ncat: "spool", i18ncat: "spool",
actions,
dataSource,
tableState,
width: 150, width: 150,
}), }),
ActionsColumn(actions), ActionsColumn(actions),

View File

@@ -15,4 +15,8 @@ export interface ISpool {
lot_nr?: string; lot_nr?: string;
comment?: string; comment?: string;
archived: boolean; archived: boolean;
extra: { [key: string]: string };
} }
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };

View File

@@ -8,6 +8,8 @@ import utc from "dayjs/plugin/utc";
import { ISpool } from "./model"; import { ISpool } from "./model";
import { enrichText } from "../../utils/parsing"; import { enrichText } from "../../utils/parsing";
import { IFilament } from "../filaments/model"; import { IFilament } from "../filaments/model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldDisplay } from "../../components/extraFields";
dayjs.extend(utc); dayjs.extend(utc);
@@ -15,6 +17,7 @@ const { Title } = Typography;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => { export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
const { queryResult } = useShow<ISpool>({ const { queryResult } = useShow<ISpool>({
liveMode: "auto", liveMode: "auto",
@@ -25,12 +28,12 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const spoolPrice = (item: ISpool) => { const spoolPrice = (item: ISpool) => {
let spoolPrice = ""; let spoolPrice = "";
if (!item.price){ if (!item.price) {
spoolPrice = `${item.filament.price}`; spoolPrice = `${item.filament.price}`;
return spoolPrice; return spoolPrice;
} }
return item.price; return item.price;
} };
const formatFilament = (item: IFilament) => { const formatFilament = (item: IFilament) => {
let vendorPrefix = ""; let vendorPrefix = "";
@@ -133,6 +136,10 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
<TextField value={enrichText(record?.comment)} /> <TextField value={enrichText(record?.comment)} />
<Title level={5}>{t("spool.fields.archived")}</Title> <Title level={5}>{t("spool.fields.archived")}</Title>
<TextField value={record?.archived ? t("yes") : t("no")} /> <TextField value={record?.archived ? t("yes") : t("no")} />
<Title level={4}>{t("settings.extra_fields.tab")}</Title>
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))}
</Show> </Show>
); );
}; };

View File

@@ -1,9 +1,15 @@
import React from "react"; import React from "react";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Create, useForm } from "@refinedev/antd"; import { Create, useForm } from "@refinedev/antd";
import { Button, Form, Input } from "antd"; import { Button, Form, Input, Typography } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { IVendor } from "./model"; import { IVendor, IVendorParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
interface CreateOrCloneProps { interface CreateOrCloneProps {
mode: "create" | "clone"; mode: "create" | "clone";
@@ -11,15 +17,36 @@ interface CreateOrCloneProps {
export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => { export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => {
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.vendor);
const { form, formProps, formLoading, onFinish, redirect } = useForm<IVendor>(); const { form, formProps, formLoading, onFinish, redirect } = useForm<
IVendor,
HttpError,
IVendorParsedExtras,
IVendorParsedExtras
>();
if (!formProps.initialValues) {
formProps.initialValues = {};
}
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => { const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
const values = await form.validateFields(); const values = StringifiedExtras(await form.validateFields());
await onFinish(values); await onFinish(values);
redirect(redirectTo, (values as IVendor).id); redirect(redirectTo, (values as IVendor).id);
}; };
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
React.useEffect(() => {
extraFields.data?.forEach((field) => {
if (formProps.initialValues && field.default_value) {
const parsedValue = JSON.parse(field.default_value as string);
form.setFieldsValue({ extra: { [field.key]: parsedValue } });
}
});
}, [form, extraFields.data, formProps.initialValues]);
return ( return (
<Create <Create
title={props.mode === "create" ? t("vendor.titles.create") : t("vendor.titles.clone")} title={props.mode === "create" ? t("vendor.titles.create") : t("vendor.titles.clone")}
@@ -58,6 +85,10 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
> >
<TextArea maxLength={1024} /> <TextArea maxLength={1024} />
</Form.Item> </Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form> </Form>
</Create> </Create>
); );

View File

@@ -1,25 +1,52 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { IResourceComponentsProps, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd"; import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker, message, Alert } from "antd"; import { Form, Input, DatePicker, message, Alert, Typography } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { IVendor } from "./model"; import { IVendor, IVendorParsedExtras } from "./model";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
import { ParsedExtras } from "../../components/extraFields";
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
in order for Ant design's form to work properly. ParsedExtras does this for us.
We also need to stringify them again before sending them back to the API, which is done by overriding
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
*/
export const VendorEdit: React.FC<IResourceComponentsProps> = () => { export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false); const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.vendor);
const { formProps, saveButtonProps } = useForm<IVendor>({ const { formProps, saveButtonProps } = useForm<IVendor, HttpError, IVendorParsedExtras, IVendorParsedExtras>({
liveMode: "manual", liveMode: "manual",
onLiveEvent() { onLiveEvent() {
// Warn the user if the vendor has been updated since the form was opened // Warn the user if the vendor has been updated since the form was opened
messageApi.warning(t("vendor.form.vendor_updated")); messageApi.warning(t("vendor.form.vendor_updated"));
setHasChanged(true); setHasChanged(true);
}, },
queryOptions: {
select(data) {
return {
data: ParsedExtras(data.data),
};
},
},
}); });
// Override the form's onFinish method to stringify the extra fields
const originalOnFinish = formProps.onFinish;
formProps.onFinish = (allValues: IVendorParsedExtras) => {
if (allValues !== undefined && allValues !== null) {
allValues = StringifiedExtras(allValues);
}
originalOnFinish?.(allValues);
};
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
{contextHolder} {contextHolder}
@@ -71,6 +98,10 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
> >
<TextArea maxLength={1024} /> <TextArea maxLength={1024} />
</Form.Item> </Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form> </Form>
{hasChanged && <Alert description={t("vendor.form.vendor_updated")} type="warning" showIcon />} {hasChanged && <Alert description={t("vendor.form.vendor_updated")} type="warning" showIcon />}
</Edit> </Edit>

View File

@@ -7,9 +7,11 @@ import utc from "dayjs/plugin/utc";
import { IVendor } from "./model"; import { IVendor } from "./model";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { EditOutlined, EyeOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons"; import { EditOutlined, EyeOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
import { DateColumn, RichColumn, SortedColumn, ActionsColumn } from "../../components/column"; import { DateColumn, RichColumn, SortedColumn, ActionsColumn, CustomFieldColumn } from "../../components/column";
import { useLiveify } from "../../components/liveify"; import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering"; import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useNavigate } from "react-router-dom";
dayjs.extend(utc); dayjs.extend(utc);
@@ -20,6 +22,10 @@ const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "com
export const VendorList: React.FC<IResourceComponentsProps> = () => { export const VendorList: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const invalidate = useInvalidate(); const invalidate = useInvalidate();
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.vendor);
const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
// Load initial state // Load initial state
const initialState = useInitialTableState(namespace); const initialState = useInitialTableState(namespace);
@@ -82,6 +88,15 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
{ name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("vendor", record.id) }, { name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("vendor", record.id) },
]; ];
const commonProps = {
t,
navigate,
actions,
dataSource,
tableState,
sorter: true,
};
return ( return (
<List <List
headerButtons={({ defaultButtons }) => ( headerButtons={({ defaultButtons }) => (
@@ -100,10 +115,20 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
<Dropdown <Dropdown
trigger={["click"]} trigger={["click"]}
menu={{ menu={{
items: allColumns.map((column) => ({ items: allColumnsWithExtraFields.map((column_id) => {
key: column, if (column_id.indexOf("extra.") === 0) {
label: t(`vendor.fields.${column}`), const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id);
})), return {
key: column_id,
label: extraField?.name ?? column_id,
};
}
return {
key: column_id,
label: t(`vendor.fields.${column_id}`),
};
}),
selectedKeys: showColumns, selectedKeys: showColumns,
selectable: true, selectable: true,
multiple: true, multiple: true,
@@ -132,33 +157,31 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
rowKey="id" rowKey="id"
columns={removeUndefined([ columns={removeUndefined([
SortedColumn({ SortedColumn({
...commonProps,
id: "id", id: "id",
i18ncat: "vendor", i18ncat: "vendor",
actions,
dataSource,
tableState,
width: 70, width: 70,
}), }),
SortedColumn({ SortedColumn({
...commonProps,
id: "name", id: "name",
i18ncat: "vendor", i18ncat: "vendor",
actions,
dataSource,
tableState,
}), }),
DateColumn({ DateColumn({
...commonProps,
id: "registered", id: "registered",
i18ncat: "vendor", i18ncat: "vendor",
actions,
dataSource,
tableState,
}), }),
...(extraFields.data?.map((field) => {
return CustomFieldColumn({
...commonProps,
field,
});
}) ?? []),
RichColumn({ RichColumn({
...commonProps,
id: "comment", id: "comment",
i18ncat: "vendor", i18ncat: "vendor",
actions,
dataSource,
tableState,
}), }),
ActionsColumn<IVendor>(actions), ActionsColumn<IVendor>(actions),
])} ])}

View File

@@ -3,4 +3,8 @@ export interface IVendor {
registered: string; registered: string;
name: string; name: string;
comment?: string; comment?: string;
extra: { [key: string]: string };
} }
// IVendorParsedExtras is the same as IVendor, but with the extra field parsed into its real types
export type IVendorParsedExtras = Omit<IVendor, "extra"> & { extra?: { [key: string]: unknown } };

View File

@@ -6,6 +6,8 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { IVendor } from "./model"; import { IVendor } from "./model";
import { enrichText } from "../../utils/parsing"; import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { ExtraFieldDisplay } from "../../components/extraFields";
dayjs.extend(utc); dayjs.extend(utc);
@@ -13,6 +15,7 @@ const { Title } = Typography;
export const VendorShow: React.FC<IResourceComponentsProps> = () => { export const VendorShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.vendor);
const { queryResult } = useShow<IVendor>({ const { queryResult } = useShow<IVendor>({
liveMode: "auto", liveMode: "auto",
@@ -39,6 +42,10 @@ export const VendorShow: React.FC<IResourceComponentsProps> = () => {
<TextField value={record?.name} /> <TextField value={record?.name} />
<Title level={5}>{t("vendor.fields.comment")}</Title> <Title level={5}>{t("vendor.fields.comment")}</Title>
<TextField value={enrichText(record?.comment)} /> <TextField value={enrichText(record?.comment)} />
<Title level={4}>{t("settings.extra_fields.tab")}</Title>
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))}
</Show> </Show>
); );
}; };

View File

@@ -0,0 +1,132 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
export enum FieldType {
text = "text",
integer = "integer",
integer_range = "integer_range",
float = "float",
float_range = "float_range",
datetime = "datetime",
boolean = "boolean",
choice = "choice",
}
export enum EntityType {
vendor = "vendor",
filament = "filament",
spool = "spool",
}
export interface FieldParameters {
name: string;
order: number;
unit?: string;
field_type: FieldType;
default_value?: string | (number | null)[] | boolean | dayjs.Dayjs;
choices?: string[];
multi_choice?: boolean;
}
export interface Field extends FieldParameters {
key: string;
entity_type: EntityType;
}
export function useGetFields(entity_type: EntityType) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<Field[]>({
queryKey: ["fields", entity_type],
queryFn: async () => {
const response = await fetch(`${apiEndpoint}/field/${entity_type}`);
return response.json();
},
});
}
export function useSetField(entity_type: EntityType) {
const queryClient = useQueryClient();
const apiEndpoint = import.meta.env.VITE_APIURL;
return useMutation<Field[], unknown, { key: string; params: FieldParameters }, { previousFields?: Field[] }>({
mutationFn: async ({ key, params }) => {
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
// Throw error if response is not ok
if (!response.ok) {
throw new Error((await response.json()).message);
}
return response.json();
},
onMutate: async ({ key, params }) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(["fields", entity_type]);
// Snapshot the previous value
const previousFields = queryClient.getQueryData<Field[]>(["fields", entity_type]);
// Optimistically update to the new value
queryClient.setQueryData<Field[]>(["fields", entity_type], (old) => {
if (!old) {
return [
{
key: key,
entity_type: entity_type,
...params,
},
];
}
return old.map((field) => {
if (field.key === key) {
return { ...field, ...params };
}
return field;
});
});
// Return a context object with the snapshotted value
return { previousFields };
},
onError: (_err, _newFields, context) => {
// Rollback to the previous value
if (context?.previousFields) {
queryClient.setQueryData(["fields", entity_type], context.previousFields);
}
},
onSettled: () => {
// Invalidate and refetch
queryClient.invalidateQueries(["fields", entity_type]);
},
});
}
export function useDeleteField(entity_type: EntityType) {
const queryClient = useQueryClient();
const apiEndpoint = import.meta.env.VITE_APIURL;
return useMutation<Field[], unknown, string>({
mutationFn: async (key) => {
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
method: "DELETE",
});
// Throw error if response is not ok
if (!response.ok) {
throw new Error((await response.json()).message);
}
return response.json();
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries(["fields", entity_type]);
},
});
}

View File

@@ -0,0 +1,61 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface SettingResponseValue {
value: string;
is_set: boolean;
type: string;
}
interface SettingsResponse {
[key: string]: SettingResponseValue;
}
export function useGetSettings() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<SettingsResponse>({
queryKey: ["settings"],
queryFn: async () => {
const response = await fetch(`${apiEndpoint}/setting`);
return response.json();
},
});
}
export function useGetSetting(key: string) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<SettingResponseValue>({
queryKey: ["settings", key],
queryFn: async () => {
const response = await fetch(`${apiEndpoint}/setting/${key}`);
return response.json();
},
});
}
export function useSetSetting() {
const queryClient = useQueryClient();
const apiEndpoint = import.meta.env.VITE_APIURL;
return useMutation<SettingResponseValue, unknown, { key: string; value: unknown }>({
mutationFn: async ({ key, value }) => {
const response = await fetch(`${apiEndpoint}/setting/${key}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(value),
});
// Throw error if response is not ok
if (!response.ok) {
throw new Error((await response.json()).message);
}
return response.json();
},
onSuccess: (_data, { key }) => {
// Invalidate and refetch
queryClient.invalidateQueries(["settings", key]);
},
});
}

View File

@@ -0,0 +1,74 @@
"""Added extra fields.
Revision ID: b8881bdb716c
Revises: ccbb17aeda7c
Create Date: 2024-01-04 22:09:34.417527
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b8881bdb716c"
down_revision = "ccbb17aeda7c"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Perform the upgrade."""
op.create_table(
"vendor_field",
sa.Column("vendor_id", sa.Integer(), nullable=False),
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(
["vendor_id"],
["vendor.id"],
),
sa.PrimaryKeyConstraint("vendor_id", "key"),
)
op.create_index(op.f("ix_vendor_field_key"), "vendor_field", ["key"], unique=False)
op.create_index(op.f("ix_vendor_field_vendor_id"), "vendor_field", ["vendor_id"], unique=False)
op.create_table(
"filament_field",
sa.Column("filament_id", sa.Integer(), nullable=False),
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(
["filament_id"],
["filament.id"],
),
sa.PrimaryKeyConstraint("filament_id", "key"),
)
op.create_index(op.f("ix_filament_field_filament_id"), "filament_field", ["filament_id"], unique=False)
op.create_index(op.f("ix_filament_field_key"), "filament_field", ["key"], unique=False)
op.create_table(
"spool_field",
sa.Column("spool_id", sa.Integer(), nullable=False),
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(
["spool_id"],
["spool.id"],
),
sa.PrimaryKeyConstraint("spool_id", "key"),
)
op.create_index(op.f("ix_spool_field_key"), "spool_field", ["key"], unique=False)
op.create_index(op.f("ix_spool_field_spool_id"), "spool_field", ["spool_id"], unique=False)
def downgrade() -> None:
"""Perform the downgrade."""
op.drop_index(op.f("ix_spool_field_spool_id"), table_name="spool_field")
op.drop_index(op.f("ix_spool_field_key"), table_name="spool_field")
op.drop_table("spool_field")
op.drop_index(op.f("ix_filament_field_key"), table_name="filament_field")
op.drop_index(op.f("ix_filament_field_filament_id"), table_name="filament_field")
op.drop_table("filament_field")
op.drop_index(op.f("ix_vendor_field_vendor_id"), table_name="vendor_field")
op.drop_index(op.f("ix_vendor_field_key"), table_name="vendor_field")
op.drop_table("vendor_field")

View File

@@ -83,6 +83,7 @@ target-version = "py39"
"S101", "S101",
"PLR2004", "PLR2004",
"D103", "D103",
"TID252",
] ]
"migrations/versions/*" = [ "migrations/versions/*" = [
"N999", "N999",

98
spoolman/api/v1/field.py Normal file
View File

@@ -0,0 +1,98 @@
"""Vendor related endpoints."""
import logging
from typing import Annotated, Union
from fastapi import APIRouter, Depends, Path
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message
from spoolman.database.database import get_db_session
from spoolman.exceptions import ItemNotFoundError
from spoolman.extra_fields import (
EntityType,
ExtraField,
ExtraFieldParameters,
add_or_update_extra_field,
delete_extra_field,
get_extra_fields,
)
router = APIRouter(
prefix="/field",
tags=["field"],
)
# ruff: noqa: D103,B008
logger = logging.getLogger(__name__)
@router.get(
"/{entity_type}",
name="Get extra fields",
description="Get all extra fields for a specific entity type.",
response_model_exclude_none=True,
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
entity_type: Annotated[EntityType, Path(description="Entity type this field is for")],
) -> list[ExtraField]:
return await get_extra_fields(db, entity_type)
@router.post(
"/{entity_type}/{key}",
name="Add or update extra field",
description=(
"Add or update an extra field for a specific entity type. "
"Returns the full list of extra fields for the entity type."
),
response_model_exclude_none=True,
response_model=list[ExtraField],
responses={400: {"model": Message}},
)
async def update(
db: Annotated[AsyncSession, Depends(get_db_session)],
entity_type: Annotated[EntityType, Path(description="Entity type this field is for")],
key: Annotated[str, Path(min_length=1, max_length=64, regex="^[a-z0-9_]+$")],
body: ExtraFieldParameters,
) -> Union[list[ExtraField], JSONResponse]:
body_with_key = ExtraField.parse_obj(body.copy(update={"key": key, "entity_type": entity_type}))
try:
await add_or_update_extra_field(db, entity_type, body_with_key)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
return await get_extra_fields(db, entity_type)
@router.delete(
"/{entity_type}/{key}",
name="Delete extra field",
description=(
"Delete an extra field for a specific entity type. "
"Returns the full list of extra fields for the entity type."
),
response_model_exclude_none=True,
response_model=list[ExtraField],
responses={404: {"model": Message}},
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
entity_type: Annotated[EntityType, Path(description="Entity type this field is for")],
key: Annotated[str, Path(min_length=1, max_length=64, regex="^[a-z0-9_]+$")],
) -> Union[list[ExtraField], JSONResponse]:
try:
await delete_extra_field(db, entity_type, key)
except ItemNotFoundError:
return JSONResponse(
status_code=404,
content=Message(
message=f"Extra field with key {key} does not exist for entity type {entity_type.name}",
).dict(),
)
return await get_extra_fields(db, entity_type)

View File

@@ -17,6 +17,7 @@ from spoolman.database import filament
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemDeleteError from spoolman.exceptions import ItemDeleteError
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,6 +82,10 @@ class FilamentParameters(BaseModel):
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.", description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000", example="FF0000",
) )
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this filament.",
)
@validator("color_hex") @validator("color_hex")
@classmethod @classmethod
@@ -323,11 +328,20 @@ async def notify(
name="Add filament", name="Add filament",
description="Add a new filament to the database.", description="Add a new filament to the database.",
response_model_exclude_none=True, response_model_exclude_none=True,
response_model=Filament,
responses={400: {"model": Message}},
) )
async def create( async def create( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)], db: Annotated[AsyncSession, Depends(get_db_session)],
body: FilamentParameters, body: FilamentParameters,
) -> Filament: ):
if body.extra:
all_fields = await get_extra_fields(db, EntityType.filament)
try:
validate_extra_field_dict(all_fields, body.extra)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
db_item = await filament.create( db_item = await filament.create(
db=db, db=db,
density=body.density, density=body.density,
@@ -343,6 +357,7 @@ async def create(
settings_extruder_temp=body.settings_extruder_temp, settings_extruder_temp=body.settings_extruder_temp,
settings_bed_temp=body.settings_bed_temp, settings_bed_temp=body.settings_bed_temp,
color_hex=body.color_hex, color_hex=body.color_hex,
extra=body.extra,
) )
return Filament.from_db(db_item) return Filament.from_db(db_item)
@@ -351,15 +366,22 @@ async def create(
@router.patch( @router.patch(
"/{filament_id}", "/{filament_id}",
name="Update filament", name="Update filament",
description="Update any attribute of a filament. Only fields specified in the request will be affected.", description=(
"Update any attribute of a filament. Only fields specified in the request will be affected. "
"If extra is set, all existing extra fields will be removed and replaced with the new ones."
),
response_model_exclude_none=True, response_model_exclude_none=True,
responses={404: {"model": Message}}, response_model=Filament,
responses={
400: {"model": Message},
404: {"model": Message},
},
) )
async def update( async def update( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)], db: Annotated[AsyncSession, Depends(get_db_session)],
filament_id: int, filament_id: int,
body: FilamentUpdateParameters, body: FilamentUpdateParameters,
) -> Filament: ):
patch_data = body.dict(exclude_unset=True) patch_data = body.dict(exclude_unset=True)
if "density" in patch_data and body.density is None: if "density" in patch_data and body.density is None:
@@ -367,6 +389,13 @@ async def update(
if "diameter" in patch_data and body.diameter is None: if "diameter" in patch_data and body.diameter is None:
raise RequestValidationError([ErrorWrapper(ValueError("diameter cannot be unset"), ("query", "diameter"))]) raise RequestValidationError([ErrorWrapper(ValueError("diameter cannot be unset"), ("query", "diameter"))])
if body.extra:
all_fields = await get_extra_fields(db, EntityType.filament)
try:
validate_extra_field_dict(all_fields, body.extra)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
db_item = await filament.update( db_item = await filament.update(
db=db, db=db,
filament_id=filament_id, filament_id=filament_id,

View File

@@ -60,6 +60,12 @@ class Vendor(BaseModel):
registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.") registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.")
name: str = Field(max_length=64, description="Vendor name.", example="Polymaker") name: str = Field(max_length=64, description="Vendor name.", example="Polymaker")
comment: Optional[str] = Field(max_length=1024, description="Free text comment about this vendor.", example="") comment: Optional[str] = Field(max_length=1024, description="Free text comment about this vendor.", example="")
extra: dict[str, str] = Field(
description=(
"Extra fields for this vendor. All values are JSON-encoded data. "
"Query the /fields endpoint for more details about the fields."
),
)
@staticmethod @staticmethod
def from_db(item: models.Vendor) -> "Vendor": def from_db(item: models.Vendor) -> "Vendor":
@@ -69,6 +75,7 @@ class Vendor(BaseModel):
registered=item.registered, registered=item.registered,
name=item.name, name=item.name,
comment=item.comment, comment=item.comment,
extra={field.key: field.value for field in item.extra},
) )
@@ -128,6 +135,12 @@ class Filament(BaseModel):
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.", description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000", example="FF0000",
) )
extra: dict[str, str] = Field(
description=(
"Extra fields for this filament. All values are JSON-encoded data. "
"Query the /fields endpoint for more details about the fields."
),
)
@staticmethod @staticmethod
def from_db(item: models.Filament) -> "Filament": def from_db(item: models.Filament) -> "Filament":
@@ -148,6 +161,7 @@ class Filament(BaseModel):
settings_extruder_temp=item.settings_extruder_temp, settings_extruder_temp=item.settings_extruder_temp,
settings_bed_temp=item.settings_bed_temp, settings_bed_temp=item.settings_bed_temp,
color_hex=item.color_hex, color_hex=item.color_hex,
extra={field.key: field.value for field in item.extra},
) )
@@ -198,6 +212,12 @@ class Spool(BaseModel):
example="", example="",
) )
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.") archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
extra: dict[str, str] = Field(
description=(
"Extra fields for this spool. All values are JSON-encoded data. "
"Query the /fields endpoint for more details about the fields."
),
)
@staticmethod @staticmethod
def from_db(item: models.Spool) -> "Spool": def from_db(item: models.Spool) -> "Spool":
@@ -235,6 +255,7 @@ class Spool(BaseModel):
lot_nr=item.lot_nr, lot_nr=item.lot_nr,
comment=item.comment, comment=item.comment,
archived=item.archived if item.archived is not None else False, archived=item.archived if item.archived is not None else False,
extra={field.key: field.value for field in item.extra},
) )

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
from . import filament, models, other, setting, spool, vendor from . import field, filament, models, other, setting, spool, vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -108,4 +108,5 @@ app.include_router(filament.router)
app.include_router(spool.router) app.include_router(spool.router)
app.include_router(vendor.router) app.include_router(vendor.router)
app.include_router(setting.router) app.include_router(setting.router)
app.include_router(field.router)
app.include_router(other.router) app.include_router(other.router)

View File

@@ -18,6 +18,7 @@ from spoolman.database import spool
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemCreateError from spoolman.exceptions import ItemCreateError
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,6 +60,10 @@ class SpoolParameters(BaseModel):
example="", example="",
) )
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.") archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this spool.",
)
class SpoolUpdateParameters(SpoolParameters): class SpoolUpdateParameters(SpoolParameters):
@@ -337,6 +342,13 @@ async def create( # noqa: ANN201
content={"message": "Only specify either remaining_weight or used_weight."}, content={"message": "Only specify either remaining_weight or used_weight."},
) )
if body.extra:
all_fields = await get_extra_fields(db, EntityType.spool)
try:
validate_extra_field_dict(all_fields, body.extra)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
try: try:
db_item = await spool.create( db_item = await spool.create(
db=db, db=db,
@@ -350,6 +362,7 @@ async def create( # noqa: ANN201
lot_nr=body.lot_nr, lot_nr=body.lot_nr,
comment=body.comment, comment=body.comment,
archived=body.archived, archived=body.archived,
extra=body.extra,
) )
return Spool.from_db(db_item) return Spool.from_db(db_item)
except ItemCreateError: except ItemCreateError:
@@ -366,7 +379,8 @@ async def create( # noqa: ANN201
description=( description=(
"Update any attribute of a spool. " "Update any attribute of a spool. "
"Only fields specified in the request will be affected. " "Only fields specified in the request will be affected. "
"remaining_weight and used_weight can't be set at the same time." "remaining_weight and used_weight can't be set at the same time. "
"If extra is set, all existing extra fields will be removed and replaced with the new ones."
), ),
response_model_exclude_none=True, response_model_exclude_none=True,
response_model=Spool, response_model=Spool,
@@ -388,6 +402,13 @@ async def update( # noqa: ANN201
content={"message": "Only specify either remaining_weight or used_weight."}, content={"message": "Only specify either remaining_weight or used_weight."},
) )
if body.extra:
all_fields = await get_extra_fields(db, EntityType.spool)
try:
validate_extra_field_dict(all_fields, body.extra)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
if "filament_id" in patch_data and body.filament_id is None: if "filament_id" in patch_data and body.filament_id is None:
raise RequestValidationError( raise RequestValidationError(
[ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))], [ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))],

View File

@@ -15,6 +15,7 @@ from spoolman.api.v1.models import Message, Vendor, VendorEvent
from spoolman.database import vendor from spoolman.database import vendor
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder from spoolman.database.utils import SortOrder
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
router = APIRouter( router = APIRouter(
@@ -32,6 +33,10 @@ class VendorParameters(BaseModel):
description="Free text comment about this vendor.", description="Free text comment about this vendor.",
example="", example="",
) )
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this vendor.",
)
class VendorUpdateParameters(VendorParameters): class VendorUpdateParameters(VendorParameters):
@@ -167,15 +172,25 @@ async def notify(
name="Add vendor", name="Add vendor",
description="Add a new vendor to the database.", description="Add a new vendor to the database.",
response_model_exclude_none=True, response_model_exclude_none=True,
response_model=Vendor,
responses={400: {"model": Message}},
) )
async def create( async def create( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)], db: Annotated[AsyncSession, Depends(get_db_session)],
body: VendorParameters, body: VendorParameters,
) -> Vendor: ):
if body.extra:
all_fields = await get_extra_fields(db, EntityType.vendor)
try:
validate_extra_field_dict(all_fields, body.extra)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
db_item = await vendor.create( db_item = await vendor.create(
db=db, db=db,
name=body.name, name=body.name,
comment=body.comment, comment=body.comment,
extra=body.extra,
) )
return Vendor.from_db(db_item) return Vendor.from_db(db_item)
@@ -184,20 +199,34 @@ async def create(
@router.patch( @router.patch(
"/{vendor_id}", "/{vendor_id}",
name="Update vendor", name="Update vendor",
description="Update any attribute of a vendor. Only fields specified in the request will be affected.", description=(
"Update any attribute of a vendor. Only fields specified in the request will be affected. "
"If extra is set, all existing extra fields will be removed and replaced with the new ones."
),
response_model_exclude_none=True, response_model_exclude_none=True,
responses={404: {"model": Message}}, response_model=Vendor,
responses={
400: {"model": Message},
404: {"model": Message},
},
) )
async def update( async def update( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)], db: Annotated[AsyncSession, Depends(get_db_session)],
vendor_id: int, vendor_id: int,
body: VendorUpdateParameters, body: VendorUpdateParameters,
) -> Vendor: ):
patch_data = body.dict(exclude_unset=True) patch_data = body.dict(exclude_unset=True)
if "name" in patch_data and body.name is None: if "name" in patch_data and body.name is None:
raise RequestValidationError([ErrorWrapper(ValueError("name cannot be unset"), ("query", "name"))]) raise RequestValidationError([ErrorWrapper(ValueError("name cannot be unset"), ("query", "name"))])
if body.extra:
all_fields = await get_extra_fields(db, EntityType.vendor)
try:
validate_extra_field_dict(all_fields, body.extra)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
db_item = await vendor.update( db_item = await vendor.update(
db=db, db=db,
vendor_id=vendor_id, vendor_id=vendor_id,

View File

@@ -5,6 +5,7 @@ from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from typing import Optional, Union from typing import Optional, Union
import sqlalchemy
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -41,6 +42,7 @@ async def create(
settings_extruder_temp: Optional[int] = None, settings_extruder_temp: Optional[int] = None,
settings_bed_temp: Optional[int] = None, settings_bed_temp: Optional[int] = None,
color_hex: Optional[str] = None, color_hex: Optional[str] = None,
extra: Optional[dict[str, str]] = None,
) -> models.Filament: ) -> models.Filament:
"""Add a new filament to the database.""" """Add a new filament to the database."""
vendor_item: Optional[models.Vendor] = None vendor_item: Optional[models.Vendor] = None
@@ -62,6 +64,7 @@ async def create(
settings_extruder_temp=settings_extruder_temp, settings_extruder_temp=settings_extruder_temp,
settings_bed_temp=settings_bed_temp, settings_bed_temp=settings_bed_temp,
color_hex=color_hex, color_hex=color_hex,
extra=[models.FilamentField(key=k, value=v) for k, v in (extra or {}).items()],
) )
db.add(filament) db.add(filament)
await db.commit() await db.commit()
@@ -131,7 +134,7 @@ async def find(
stmt = stmt.order_by(field.desc()) stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
result = list(rows.scalars().all()) result = list(rows.unique().scalars().all())
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
@@ -152,6 +155,8 @@ async def update(
filament.vendor = None filament.vendor = None
else: else:
filament.vendor = await vendor.get_by_id(db, v) filament.vendor = await vendor.get_by_id(db, v)
elif k == "extra":
filament.extra = [models.FilamentField(key=k, value=v) for k, v in v.items()]
else: else:
setattr(filament, k, v) setattr(filament, k, v)
await db.commit() await db.commit()
@@ -171,6 +176,13 @@ async def delete(db: AsyncSession, filament_id: int) -> None:
raise ItemDeleteError("Failed to delete filament.") from exc raise ItemDeleteError("Failed to delete filament.") from exc
async def clear_extra_field(db: AsyncSession, key: str) -> None:
"""Delete all extra fields with a specific key."""
await db.execute(
sqlalchemy.delete(models.FilamentField).where(models.FilamentField.key == key),
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -4,10 +4,11 @@ from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase): class Base(AsyncAttrs, DeclarativeBase):
pass pass
@@ -19,6 +20,11 @@ class Vendor(Base):
name: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024)) comment: Mapped[Optional[str]] = mapped_column(String(1024))
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor") filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
extra: Mapped[list["VendorField"]] = relationship(
back_populates="vendor",
cascade="save-update, merge, delete, delete-orphan",
lazy="joined",
)
class Filament(Base): class Filament(Base):
@@ -41,6 +47,11 @@ class Filament(Base):
settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.") settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.")
settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.") settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.")
color_hex: Mapped[Optional[str]] = mapped_column(String(8)) color_hex: Mapped[Optional[str]] = mapped_column(String(8))
extra: Mapped[list["FilamentField"]] = relationship(
back_populates="filament",
cascade="save-update, merge, delete, delete-orphan",
lazy="joined",
)
class Spool(Base): class Spool(Base):
@@ -58,6 +69,11 @@ class Spool(Base):
lot_nr: Mapped[Optional[str]] = mapped_column(String(64)) lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024)) comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column() archived: Mapped[Optional[bool]] = mapped_column()
extra: Mapped[list["SpoolField"]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
lazy="joined",
)
class Setting(Base): class Setting(Base):
@@ -66,3 +82,30 @@ class Setting(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text()) value: Mapped[str] = mapped_column(Text())
last_updated: Mapped[datetime] = mapped_column() last_updated: Mapped[datetime] = mapped_column()
class VendorField(Base):
__tablename__ = "vendor_field"
vendor_id: Mapped[int] = mapped_column(ForeignKey("vendor.id"), primary_key=True, index=True)
vendor: Mapped["Vendor"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class FilamentField(Base):
__tablename__ = "filament_field"
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"), primary_key=True, index=True)
filament: Mapped["Filament"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class SpoolField(Base):
__tablename__ = "spool_field"
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), primary_key=True, index=True)
spool: Mapped["Spool"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())

View File

@@ -43,6 +43,7 @@ async def create(
lot_nr: Optional[str] = None, lot_nr: Optional[str] = None,
comment: Optional[str] = None, comment: Optional[str] = None,
archived: bool = False, archived: bool = False,
extra: Optional[dict[str, str]] = None,
) -> models.Spool: ) -> models.Spool:
"""Add a new spool to the database. Leave weight empty to assume full spool.""" """Add a new spool to the database. Leave weight empty to assume full spool."""
filament_item = await filament.get_by_id(db, filament_id) filament_item = await filament.get_by_id(db, filament_id)
@@ -71,6 +72,7 @@ async def create(
lot_nr=lot_nr, lot_nr=lot_nr,
comment=comment, comment=comment,
archived=archived, archived=archived,
extra=[models.SpoolField(key=k, value=v) for k, v in (extra or {}).items()],
) )
db.add(spool) db.add(spool)
await db.commit() await db.commit()
@@ -158,7 +160,7 @@ async def find(
stmt = stmt.order_by(field.desc()) stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
result = list(rows.scalars().all()) result = list(rows.unique().scalars().all())
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
@@ -182,6 +184,8 @@ async def update(
spool.used_weight = max(spool.filament.weight - v, 0) spool.used_weight = max(spool.filament.weight - v, 0)
elif isinstance(v, datetime): elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v)) setattr(spool, k, utc_timezone_naive(v))
elif k == "extra":
spool.extra = [models.SpoolField(key=k, value=v) for k, v in v.items()]
else: else:
setattr(spool, k, v) setattr(spool, k, v)
await db.commit() await db.commit()
@@ -196,6 +200,13 @@ async def delete(db: AsyncSession, spool_id: int) -> None:
await spool_changed(spool, EventType.DELETED) await spool_changed(spool, EventType.DELETED)
async def clear_extra_field(db: AsyncSession, key: str) -> None:
"""Delete all extra fields with a specific key."""
await db.execute(
sqlalchemy.delete(models.SpoolField).where(models.SpoolField.key == key),
)
async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> None: async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> None:
"""Consume filament from a spool by weight in a way that is safe against race conditions. """Consume filament from a spool by weight in a way that is safe against race conditions.

View File

@@ -3,6 +3,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
import sqlalchemy
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,12 +19,14 @@ async def create(
db: AsyncSession, db: AsyncSession,
name: Optional[str] = None, name: Optional[str] = None,
comment: Optional[str] = None, comment: Optional[str] = None,
extra: Optional[dict[str, str]] = None,
) -> models.Vendor: ) -> models.Vendor:
"""Add a new vendor to the database.""" """Add a new vendor to the database."""
vendor = models.Vendor( vendor = models.Vendor(
name=name, name=name,
registered=datetime.utcnow().replace(microsecond=0), registered=datetime.utcnow().replace(microsecond=0),
comment=comment, comment=comment,
extra=[models.VendorField(key=k, value=v) for k, v in (extra or {}).items()],
) )
db.add(vendor) db.add(vendor)
await db.commit() await db.commit()
@@ -72,7 +75,7 @@ async def find(
stmt = stmt.order_by(field.desc()) stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
result = list(rows.scalars().all()) result = list(rows.unique().scalars().all())
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
@@ -88,7 +91,10 @@ async def update(
"""Update the fields of a vendor object.""" """Update the fields of a vendor object."""
vendor = await get_by_id(db, vendor_id) vendor = await get_by_id(db, vendor_id)
for k, v in data.items(): for k, v in data.items():
setattr(vendor, k, v) if k == "extra":
vendor.extra = [models.VendorField(key=k, value=v) for k, v in v.items()]
else:
setattr(vendor, k, v)
await db.commit() await db.commit()
await vendor_changed(vendor, EventType.UPDATED) await vendor_changed(vendor, EventType.UPDATED)
return vendor return vendor
@@ -101,6 +107,13 @@ async def delete(db: AsyncSession, vendor_id: int) -> None:
await vendor_changed(vendor, EventType.DELETED) await vendor_changed(vendor, EventType.DELETED)
async def clear_extra_field(db: AsyncSession, key: str) -> None:
"""Delete all extra fields with a specific key."""
await db.execute(
sqlalchemy.delete(models.VendorField).where(models.VendorField.key == key),
)
async def vendor_changed(vendor: models.Vendor, typ: EventType) -> None: async def vendor_changed(vendor: models.Vendor, typ: EventType) -> None:
"""Notify websocket clients that a vendor has changed.""" """Notify websocket clients that a vendor has changed."""
await websocket_manager.send( await websocket_manager.send(

246
spoolman/extra_fields.py Normal file
View File

@@ -0,0 +1,246 @@
"""Custom/extra fields for spoolman entities."""
import json
import logging
from enum import Enum
from typing import Optional
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament as db_filament
from spoolman.database import setting as db_setting
from spoolman.database import spool as db_spool
from spoolman.database import vendor as db_vendor
from spoolman.exceptions import ItemNotFoundError
from spoolman.settings import parse_setting
logger = logging.getLogger(__name__)
class EntityType(Enum):
vendor = "vendor"
filament = "filament"
spool = "spool"
class ExtraFieldType(Enum):
text = "text"
integer = "integer"
integer_range = "integer_range"
float = "float"
float_range = "float_range"
datetime = "datetime"
boolean = "boolean"
choice = "choice"
class ExtraFieldParameters(BaseModel):
name: str = Field(description="Nice name", min_length=1, max_length=128)
order: int = Field(0, description="Order of the field")
unit: Optional[str] = Field(None, description="Unit of the value", min_length=1, max_length=16)
field_type: ExtraFieldType = Field(description="Type of the field")
default_value: Optional[str] = Field(None, description="Default value of the field")
choices: Optional[list[str]] = Field(
None,
description="Choices for the field, only for field type choice",
min_items=1,
)
multi_choice: Optional[bool] = Field(None, description="Whether multiple choices can be selected")
class ExtraField(ExtraFieldParameters):
key: str = Field(description="Unique key", regex="^[a-z0-9_]+$", min_length=1, max_length=64)
entity_type: EntityType = Field(description="Entity type this field is for")
def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None: # noqa: C901, PLR0912
"""Validate that the value has the correct type."""
try:
data = json.loads(value)
except json.JSONDecodeError:
raise ValueError("Value is not valid JSON.") from None
if field.field_type == ExtraFieldType.text:
if not isinstance(data, str):
raise ValueError("Value is not a string.")
elif field.field_type == ExtraFieldType.integer:
if not isinstance(data, int):
raise ValueError("Value is not an integer.")
elif field.field_type == ExtraFieldType.integer_range:
if not isinstance(data, list):
raise ValueError("Value is not a list.")
if len(data) != 2: # noqa: PLR2004
raise ValueError("Value list must have exactly two values.")
if not all(isinstance(value, int) or value is None for value in data):
raise ValueError("Value list must contain only integers or null.")
elif field.field_type == ExtraFieldType.float:
if not isinstance(data, (float, int)) or isinstance(data, bool):
raise ValueError("Value is not a float.")
elif field.field_type == ExtraFieldType.float_range:
if not isinstance(data, list):
raise ValueError("Value is not a list.")
if len(data) != 2: # noqa: PLR2004
raise ValueError("Value list must have exactly two values.")
if not all(
(isinstance(value, (float, int)) or value is None) and not isinstance(value, bool) for value in data
):
raise ValueError("Value list must contain only floats or null.")
elif field.field_type == ExtraFieldType.datetime:
if not isinstance(data, str):
raise ValueError("Value is not a string.")
elif field.field_type == ExtraFieldType.boolean:
if not isinstance(data, bool):
raise ValueError("Value is not a boolean.")
elif field.field_type == ExtraFieldType.choice:
if field.multi_choice:
if not isinstance(data, list):
raise ValueError("Value is not a list.")
if not all(isinstance(value, str) for value in data):
raise ValueError("Value list must contain only strings.")
if field.choices is not None and not all(value in field.choices for value in data):
raise ValueError("Value list contains invalid choices.")
else:
if not isinstance(data, str):
raise ValueError("Value is not a string.")
if field.choices is not None and data not in field.choices:
raise ValueError("Value is not a valid choice.")
else:
raise ValueError(f"Unknown field type {field.field_type}.")
def validate_extra_field(field: ExtraFieldParameters) -> None:
"""Validate an extra field."""
# Validate choices exist if field type is choice
if field.field_type == ExtraFieldType.choice:
if field.choices is None:
raise ValueError("Choices must be set for field type choice.")
if field.multi_choice is None:
raise ValueError("Multi choice must be set for field type choice.")
else:
if field.choices is not None:
raise ValueError("Choices must not be set for field type other than choice.")
if field.multi_choice is not None:
raise ValueError("Multi choice must not be set for field type other than choice.")
# Validate default value data type
if field.default_value is not None:
try:
validate_extra_field_value(field, field.default_value)
except ValueError as e:
raise ValueError(f"Default value is not valid: {e}") from None
def validate_extra_field_dict(all_fields: list[ExtraField], fields_input: dict[str, str]) -> None:
"""Validate a dict of extra fields."""
all_field_lookup = {field.key: field for field in all_fields}
for key, value in fields_input.items():
if key not in all_field_lookup:
raise ValueError(f"Unknown extra field {key}.")
field = all_field_lookup[key]
try:
validate_extra_field_value(field, value)
except ValueError as e:
raise ValueError(f"Invalid extra field for key {key}: {e!s}") from None
extra_field_cache = {}
async def get_extra_fields(db: AsyncSession, entity_type: EntityType) -> list[ExtraField]:
"""Get all extra fields for a specific entity type."""
if entity_type in extra_field_cache:
return extra_field_cache[entity_type]
setting_def = parse_setting(f"extra_fields_{entity_type.name}")
try:
setting = await db_setting.get(db, setting_def)
setting_value = setting.value
except ItemNotFoundError:
setting_value = setting_def.default
setting_array = json.loads(setting_value)
if not isinstance(setting_array, list):
logger.warning("Setting %s is not a list, using default.", setting_def.key)
setting_array = []
fields = [ExtraField.parse_obj(obj) for obj in setting_array]
extra_field_cache[entity_type] = fields
return fields
async def add_or_update_extra_field(db: AsyncSession, entity_type: EntityType, extra_field: ExtraField) -> None:
"""Add or update an extra field for a specific entity type."""
validate_extra_field(extra_field)
extra_fields = await get_extra_fields(db, entity_type)
# If the field already exists, verify that we don't do anything that would break existing data
existing_field = next((field for field in extra_fields if field.key == extra_field.key), None)
if existing_field is not None:
if existing_field.field_type != extra_field.field_type:
raise ValueError("Field type cannot be changed.")
if extra_field.field_type == ExtraFieldType.choice:
# Can't change multi choice since that would break existing data
if existing_field.multi_choice != extra_field.multi_choice:
raise ValueError("Multi choice cannot be changed.")
# Verify that we have only added new choices, not removed any
if (
existing_field.choices is not None
and extra_field.choices is not None
and not all(choice in extra_field.choices for choice in existing_field.choices)
):
raise ValueError("Cannot remove existing choices.")
extra_fields = [field for field in extra_fields if field.key != extra_field.key]
extra_fields.append(extra_field)
setting_def = parse_setting(f"extra_fields_{entity_type.name}")
await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields)))
# Update cache
extra_field_cache[entity_type] = extra_fields
logger.info("Added/updated extra field %s for entity type %s.", extra_field.key, entity_type.name)
async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str) -> None:
"""Delete an extra field for a specific entity type."""
extra_fields = await get_extra_fields(db, entity_type)
# Check if the field exists
if not any(field.key == key for field in extra_fields):
raise ItemNotFoundError(f"Extra field with key {key} does not exist.")
extra_fields = [field for field in extra_fields if field.key != key]
setting_def = parse_setting(f"extra_fields_{entity_type.name}")
await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields)))
# Update cache
extra_field_cache[entity_type] = extra_fields
# Delete the extra field for all entities
if entity_type == EntityType.vendor:
await db_vendor.clear_extra_field(db, key)
elif entity_type == EntityType.filament:
await db_filament.clear_extra_field(db, key)
elif entity_type == EntityType.spool:
await db_spool.clear_extra_field(db, key)
else:
raise ValueError(f"Unknown entity type {entity_type.name}.")
logger.info("Deleted extra field %s for entity type %s.", key, entity_type.name)
async def populate_with_defaults(db: AsyncSession, entity_type: EntityType, existing: dict[str, str]) -> None:
"""Populate the given list of extra fields with defaults."""
extra_fields = await get_extra_fields(db, entity_type)
for extra_field in extra_fields:
if extra_field.default_value is None:
continue
if extra_field.key in existing:
continue
existing[extra_field.key] = extra_field.default_value

View File

@@ -62,3 +62,7 @@ def parse_setting(key: str) -> SettingDefinition:
register_setting("currency", SettingType.STRING, json.dumps("EUR")) register_setting("currency", SettingType.STRING, json.dumps("EUR"))
register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([]))
register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([]))

View File

@@ -3,6 +3,7 @@
import math import math
import os import os
import time import time
from collections.abc import Iterable
from contextlib import contextmanager from contextlib import contextmanager
from enum import StrEnum from enum import StrEnum
from typing import Any from typing import Any
@@ -240,3 +241,54 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
volume_cm3 = weight / density volume_cm3 = weight / density
volume_mm3 = volume_cm3 * 1000 volume_mm3 = volume_cm3 * 1000
return volume_mm3 / (math.pi * (diameter / 2) ** 2) return volume_mm3 / (math.pi * (diameter / 2) ** 2)
def assert_dicts_compatible(actual: Any, expected: Any, path: str = "") -> None: # noqa: ANN401
"""Assert that two dictionaries are compatible for unit testing a REST API.
Args:
actual (dict): The actual dictionary.
expected (dict): The expected dictionary.
path (str): The path to the current level in the dictionary (used for error messages).
Raises:
AssertionError: If dictionaries are not compatible.
"""
# Check if both inputs are dictionaries
if not (isinstance(actual, dict) and isinstance(expected, dict)):
raise TypeError(f"At {path}: Actual and expected values must be dictionaries.")
# Check if actual dictionary contains all keys of the expected dictionary
missing_keys = [key for key in expected if key not in actual]
if missing_keys:
raise AssertionError(f"At {path}: Missing keys in actual dictionary: {missing_keys}")
# Recursively check values if the corresponding keys exist
for key, expected_value in expected.items():
actual_value = actual[key]
subpath = f"{path}.{key}" if path else key # Update the path for the current level
# If the value is another dictionary, recurse into it
if isinstance(expected_value, dict):
assert_dicts_compatible(actual_value, expected_value, path=subpath)
elif actual_value != expected_value: # Check if values are equal
raise AssertionError(
f"At {subpath}: Values do not match. Expected: {expected_value}, Actual: {actual_value}",
)
def assert_lists_compatible(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]], sort_key: str = "id") -> None:
"""Compare two lists of items where the order of the items is not guaranteed."""
a_sorted = sorted(a, key=lambda x: x[sort_key])
b_sorted = sorted(b, key=lambda x: x[sort_key])
if len(a_sorted) != len(b_sorted):
pytest.fail(f"Lists have different lengths: {len(a_sorted)} != {len(b_sorted)}")
for a_filament, b_filament in zip(a_sorted, b_sorted):
assert_dicts_compatible(a_filament, b_filament)
def assert_httpx_success(response: httpx.Response) -> None:
"""Assert that a response is successful."""
if not response.is_success:
pytest.fail(f"Request failed: {response.status_code} {response.text}")

View File

@@ -0,0 +1 @@
"""Integration tests for the custom extra fields system."""

View File

@@ -0,0 +1,429 @@
"""Integration tests for the custom extra fields system."""
import json
from datetime import datetime, timezone
import httpx
import pytest
from ..conftest import assert_httpx_success, assert_lists_compatible
URL = "http://spoolman:8000"
def test_add_text_field():
"""Test adding a text field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
assert_httpx_success(result)
def test_add_text_field_filament():
"""Test adding a text field for filaments."""
result = httpx.post(
f"{URL}/api/v1/field/filament/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/filament/mytextfield")
assert_httpx_success(result)
def test_add_text_field_vendor():
"""Test adding a text field for vendors."""
result = httpx.post(
f"{URL}/api/v1/field/vendor/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
assert_httpx_success(result)
def test_add_integer_field():
"""Test adding an integer field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/myintegerfield",
json={
"name": "My integer field",
"unit": "mm",
"field_type": "integer",
"default_value": json.dumps(42),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/myintegerfield")
assert_httpx_success(result)
def test_add_integer_range_field():
"""Test adding an integer range field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/myintegerrangefield",
json={
"name": "My integer range field",
"field_type": "integer_range",
"default_value": json.dumps([0, 100]),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/myintegerrangefield")
assert_httpx_success(result)
def test_add_float_field():
"""Test adding a float field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/myfloatfield",
json={
"name": "My float field",
"field_type": "float",
"default_value": json.dumps(3.14),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/myfloatfield")
assert_httpx_success(result)
def test_add_float_range_field():
"""Test adding a float range field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/myfloatrangefield",
json={
"name": "My float range field",
"field_type": "float_range",
"default_value": json.dumps([0.0, 1.0]),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/myfloatrangefield")
assert_httpx_success(result)
def test_add_datetime_field():
"""Test adding a datetime field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mydatetimefield",
json={
"name": "My datetime field",
"field_type": "datetime",
"default_value": json.dumps(datetime.now(timezone.utc).isoformat()),
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mydatetimefield")
assert_httpx_success(result)
def test_add_boolean_field():
"""Test adding a boolean field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mybooleanfield",
json={
"name": "My boolean field",
"field_type": "boolean",
"default_value": json.dumps(True), # noqa: FBT003
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mybooleanfield")
assert_httpx_success(result)
@pytest.mark.parametrize(
"multi_choice",
[True, False],
)
def test_add_choice_field(multi_choice: bool): # noqa: FBT001
"""Test adding a choice field for spools."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz"],
"default_value": json.dumps(["foo"]) if multi_choice else json.dumps("foo"),
"multi_choice": multi_choice,
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
assert_httpx_success(result)
def test_add_text_field_invalid_data():
"""Test adding a text field with invalid default value."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps(42),
},
)
assert result.status_code == 400
assert result.json()["message"] == "Default value is not valid: Value is not a string."
def test_add_choice_field_without_multi_choice():
"""Test adding a choice field without multi_choice set."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz"],
"default_value": json.dumps("foo"),
},
)
assert result.status_code == 400
assert result.json()["message"] == "Multi choice must be set for field type choice."
def test_add_choice_field_invalid_choices():
"""Test adding a choice field with invalid choices."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz", {"foo": "bar"}],
"default_value": json.dumps(["foo"]),
"multi_choice": True,
},
)
assert result.status_code == 422
def test_add_choice_field_invalid_default_value():
"""Test adding a choice field with invalid default value."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz"],
"default_value": json.dumps(42),
"multi_choice": False,
},
)
assert result.status_code == 400
assert result.json()["message"] == "Default value is not valid: Value is not a string."
def test_add_choice_field_no_choices():
"""Test adding a choice field without choices set."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"default_value": json.dumps("foo"),
"multi_choice": True,
},
)
assert result.status_code == 400
assert result.json()["message"] == "Choices must be set for field type choice."
def test_update_existing_field():
"""Test updating an existing field."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Goodbye World"),
},
)
assert_httpx_success(result)
# Verify that the default value was updated, and that it is still the only field
result = httpx.get(f"{URL}/api/v1/field/spool")
assert_httpx_success(result)
assert_lists_compatible(
result.json(),
[
{
"name": "My text field",
"key": "mytextfield",
"field_type": "text",
"default_value": json.dumps("Goodbye World"),
},
],
sort_key="key",
)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
assert_httpx_success(result)
def test_update_field_change_field_type():
"""Test updating an existing field and changing the field type, should not be allowed."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "integer",
"default_value": json.dumps(42),
},
)
assert result.status_code == 400
assert result.json()["message"] == "Field type cannot be changed."
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
assert_httpx_success(result)
def test_update_field_add_choice():
"""Test updating an existing field and adding a choice, should be allowed."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz"],
"default_value": json.dumps(["foo"]),
"multi_choice": True,
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz", "qux"],
"default_value": json.dumps(["foo"]),
"multi_choice": True,
},
)
assert_httpx_success(result)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
assert_httpx_success(result)
def test_update_field_remove_choice():
"""Test updating an existing field and removing a choice, should not be allowed."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz"],
"default_value": json.dumps(["foo"]),
"multi_choice": True,
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "baz"],
"default_value": json.dumps(["foo"]),
"multi_choice": True,
},
)
assert result.status_code == 400
assert result.json()["message"] == "Cannot remove existing choices."
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
assert_httpx_success(result)
def test_update_field_change_multi_choice():
"""Test updating an existing field and changing the multi_choice setting, should not be allowed."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz"],
"default_value": json.dumps(["foo"]),
"multi_choice": True,
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/field/spool/mychoicefield",
json={
"name": "My choice field",
"field_type": "choice",
"choices": ["foo", "bar", "baz"],
"default_value": json.dumps("foo"),
"multi_choice": False,
},
)
assert result.status_code == 400
assert result.json()["message"] == "Multi choice cannot be changed."
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mychoicefield")
assert_httpx_success(result)

View File

@@ -0,0 +1,31 @@
"""Integration tests for the custom extra fields system."""
import json
import httpx
from ..conftest import assert_httpx_success
URL = "http://spoolman:8000"
def test_delete_field():
"""Test adding a field, deleting it, and making sure it's gone."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
# Delete
result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
assert_httpx_success(result)
# Verify
result = httpx.get(f"{URL}/api/v1/field/spool")
assert_httpx_success(result)
assert result.json() == []

View File

@@ -0,0 +1,60 @@
"""Integration tests for the custom extra fields system."""
import json
import httpx
from ..conftest import assert_httpx_success, assert_lists_compatible
URL = "http://spoolman:8000"
def test_get_field():
"""Test adding a couple of fields to the spool and then getting them."""
result = httpx.post(
f"{URL}/api/v1/field/spool/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/field/spool/myintfield",
json={
"name": "My int field",
"field_type": "integer",
"default_value": json.dumps(42),
},
)
assert_httpx_success(result)
result = httpx.get(f"{URL}/api/v1/field/spool")
assert_httpx_success(result)
assert_lists_compatible(
result.json(),
[
{
"name": "My text field",
"key": "mytextfield",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
{
"name": "My int field",
"key": "myintfield",
"field_type": "integer",
"default_value": json.dumps(42),
},
],
sort_key="key",
)
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/spool/mytextfield")
assert_httpx_success(result)
result = httpx.delete(f"{URL}/api/v1/field/spool/myintfield")
assert_httpx_success(result)

View File

@@ -0,0 +1,156 @@
"""Integration tests for the custom extra fields system."""
import json
import httpx
from ..conftest import assert_httpx_success
URL = "http://spoolman:8000"
def test_add_vendor_with_extra_field():
"""Test adding a vendor with a custom field."""
result = httpx.post(
f"{URL}/api/v1/field/vendor/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/vendor",
json={
"name": "My Vendor",
"extra": {
"mytextfield": '"My Value"',
},
},
)
assert_httpx_success(result)
# Verify
result = httpx.get(f"{URL}/api/v1/vendor/{result.json()['id']}")
assert_httpx_success(result)
vendor = result.json()
assert vendor["name"] == "My Vendor"
assert vendor["extra"] == {"mytextfield": '"My Value"'}
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
assert_httpx_success(result)
result = httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}")
assert_httpx_success(result)
def test_add_vendor_with_invalid_extra_field():
"""Test adding a vendor with an invalid custom field."""
result = httpx.post(
f"{URL}/api/v1/vendor",
json={
"name": "My Vendor",
"extra": {
"somefield": 42,
},
},
)
assert result.status_code == 400
assert "somefield" in result.json()["message"].lower()
def test_add_vendor_with_extra_field_then_delete():
"""Test adding a vendor with an extra field, then delete the field.
Vendor GET response should then not contain the extra field.
"""
result = httpx.post(
f"{URL}/api/v1/field/vendor/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
result = httpx.post(
f"{URL}/api/v1/vendor",
json={
"name": "My Vendor",
"extra": {
"mytextfield": '"My Value"',
},
},
)
assert_httpx_success(result)
# Verify
result = httpx.get(f"{URL}/api/v1/vendor/{result.json()['id']}")
assert_httpx_success(result)
vendor = result.json()
assert vendor["name"] == "My Vendor"
assert vendor["extra"] == {"mytextfield": '"My Value"'}
# Remove field
result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
assert_httpx_success(result)
# Verify
result = httpx.get(f"{URL}/api/v1/vendor/{vendor['id']}")
assert_httpx_success(result)
vendor = result.json()
assert vendor["name"] == "My Vendor"
assert "extra" not in vendor or vendor["extra"] == {}
result = httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}")
assert_httpx_success(result)
def test_update_existing_vendor_with_new_extra_field():
"""Test updating an existing vendor with a new extra field."""
result = httpx.post(
f"{URL}/api/v1/vendor",
json={
"name": "My Vendor",
},
)
assert_httpx_success(result)
vendor_id = result.json()["id"]
result = httpx.post(
f"{URL}/api/v1/field/vendor/mytextfield",
json={
"name": "My text field",
"field_type": "text",
"default_value": json.dumps("Hello World"),
},
)
assert_httpx_success(result)
result = httpx.patch(
f"{URL}/api/v1/vendor/{vendor_id}",
json={
"extra": {
"mytextfield": '"My Value"',
},
},
)
assert_httpx_success(result)
# Verify
result = httpx.get(f"{URL}/api/v1/vendor/{vendor_id}")
assert_httpx_success(result)
vendor = result.json()
assert vendor["name"] == "My Vendor"
assert vendor["extra"] == {"mytextfield": '"My Value"'}
# Clean up
result = httpx.delete(f"{URL}/api/v1/field/vendor/mytextfield")
assert_httpx_success(result)
result = httpx.delete(f"{URL}/api/v1/vendor/{vendor_id}")
assert_httpx_success(result)

View File

@@ -5,6 +5,8 @@ from typing import Any
import httpx import httpx
from ..conftest import assert_dicts_compatible
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
@@ -45,23 +47,26 @@ def test_add_filament(random_vendor: dict[str, Any]):
# Verify # Verify
filament = result.json() filament = result.json()
assert filament == { assert_dicts_compatible(
"id": filament["id"], filament,
"registered": filament["registered"], {
"name": name, "id": filament["id"],
"vendor": random_vendor, "registered": filament["registered"],
"material": material, "name": name,
"price": price, "vendor": random_vendor,
"density": density, "material": material,
"diameter": diameter, "price": price,
"weight": weight, "density": density,
"spool_weight": spool_weight, "diameter": diameter,
"article_number": article_number, "weight": weight,
"comment": comment, "spool_weight": spool_weight,
"settings_extruder_temp": settings_extruder_temp, "article_number": article_number,
"settings_bed_temp": settings_bed_temp, "comment": comment,
"color_hex": color_hex, "settings_extruder_temp": settings_extruder_temp,
} "settings_bed_temp": settings_bed_temp,
"color_hex": color_hex,
},
)
# Verify that registered happened almost now (within 1 minute) # Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(filament["registered"])).total_seconds()) diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(filament["registered"])).total_seconds())
@@ -87,12 +92,15 @@ def test_add_filament_required():
# Verify # Verify
filament = result.json() filament = result.json()
assert filament == { assert_dicts_compatible(
"id": filament["id"], filament,
"registered": filament["registered"], {
"density": density, "id": filament["id"],
"diameter": diameter, "registered": filament["registered"],
} "density": density,
"diameter": diameter,
},
)
# Clean up # Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()

View File

@@ -7,14 +7,11 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from ..conftest import assert_lists_compatible
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
def filament_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
"""Compare two lists of filaments where the order of the filaments is not guaranteed."""
return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
@dataclass @dataclass
class Fixture: class Fixture:
filaments: list[dict[str, Any]] filaments: list[dict[str, Any]]
@@ -120,7 +117,7 @@ def test_find_all_filaments(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments) assert_lists_compatible(filaments_result, filaments.filaments)
def test_find_all_filaments_sort_asc(filaments: Fixture): def test_find_all_filaments_sort_asc(filaments: Fixture):
@@ -279,7 +276,7 @@ def test_find_filaments_by_empty_name(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[3:]) assert_lists_compatible(filaments_result, filaments.filaments[3:])
def test_find_filaments_by_material(filaments: Fixture): def test_find_filaments_by_material(filaments: Fixture):
@@ -305,7 +302,7 @@ def test_find_filaments_by_multiple_materials(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[:2]) assert_lists_compatible(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_empty_material(filaments: Fixture): def test_find_filaments_by_empty_material(filaments: Fixture):
@@ -318,7 +315,7 @@ def test_find_filaments_by_empty_material(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[3:]) assert_lists_compatible(filaments_result, filaments.filaments[3:])
@pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"]) @pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"])
@@ -332,7 +329,7 @@ def test_find_filaments_by_vendor_id(filaments: Fixture, field_name: str):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[:2]) assert_lists_compatible(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_multiple_vendor_ids(filaments: Fixture): def test_find_filaments_by_multiple_vendor_ids(filaments: Fixture):
@@ -347,7 +344,7 @@ def test_find_filaments_by_multiple_vendor_ids(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal( assert_lists_compatible(
filaments_result, filaments_result,
[ [
filaments.filaments[0], filaments.filaments[0],
@@ -367,7 +364,7 @@ def test_find_filaments_by_empty_vendor_id(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[2:4]) assert_lists_compatible(filaments_result, filaments.filaments[2:4])
@pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"]) @pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"])
@@ -381,7 +378,7 @@ def test_find_filaments_by_vendor_name(filaments: Fixture, field_name: str):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[:2]) assert_lists_compatible(filaments_result, filaments.filaments[:2])
def test_find_filaments_by_empty_vendor_name(filaments: Fixture): def test_find_filaments_by_empty_vendor_name(filaments: Fixture):
@@ -420,7 +417,7 @@ def test_find_filaments_by_empty_article_number(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, filaments.filaments[3:]) assert_lists_compatible(filaments_result, filaments.filaments[3:])
def test_find_filaments_by_similar_color(filaments: Fixture): def test_find_filaments_by_similar_color(filaments: Fixture):
@@ -436,7 +433,7 @@ def test_find_filaments_by_similar_color(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal(filaments_result, [filaments.filaments[0], filaments.filaments[1]]) assert_lists_compatible(filaments_result, [filaments.filaments[0], filaments.filaments[1]])
def test_find_filaments_by_similar_color_100(filaments: Fixture): def test_find_filaments_by_similar_color_100(filaments: Fixture):
@@ -452,7 +449,7 @@ def test_find_filaments_by_similar_color_100(filaments: Fixture):
# Verify # Verify
filaments_result = result.json() filaments_result = result.json()
assert filament_lists_equal( assert_lists_compatible(
filaments_result, filaments_result,
[filaments.filaments[0], filaments.filaments[1], filaments.filaments[2]], [filaments.filaments[0], filaments.filaments[1], filaments.filaments[2]],
) )

View File

@@ -4,6 +4,8 @@ from typing import Any
import httpx import httpx
from ..conftest import assert_dicts_compatible
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
@@ -51,23 +53,26 @@ def test_get_filament(random_vendor: dict[str, Any]):
# Verify # Verify
filament = result.json() filament = result.json()
assert filament == { assert_dicts_compatible(
"id": added_filament["id"], filament,
"registered": added_filament["registered"], {
"name": name, "id": added_filament["id"],
"vendor": random_vendor, "registered": added_filament["registered"],
"material": material, "name": name,
"price": price, "vendor": random_vendor,
"density": density, "material": material,
"diameter": diameter, "price": price,
"weight": weight, "density": density,
"spool_weight": spool_weight, "diameter": diameter,
"article_number": article_number, "weight": weight,
"comment": comment, "spool_weight": spool_weight,
"settings_extruder_temp": settings_extruder_temp, "article_number": article_number,
"settings_bed_temp": settings_bed_temp, "comment": comment,
"color_hex": color_hex, "settings_extruder_temp": settings_extruder_temp,
} "settings_bed_temp": settings_bed_temp,
"color_hex": color_hex,
},
)
# Clean up # Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()

View File

@@ -4,6 +4,8 @@ from typing import Any
import httpx import httpx
from ..conftest import assert_dicts_compatible
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
@@ -66,23 +68,26 @@ def test_update_filament(random_vendor: dict[str, Any]):
# Verify # Verify
filament = result.json() filament = result.json()
assert filament == { assert_dicts_compatible(
"id": added_filament["id"], filament,
"registered": added_filament["registered"], {
"name": new_name, "id": added_filament["id"],
"vendor": random_vendor, "registered": added_filament["registered"],
"material": new_material, "name": new_name,
"price": new_price, "vendor": random_vendor,
"density": new_density, "material": new_material,
"diameter": new_diameter, "price": new_price,
"weight": new_weight, "density": new_density,
"spool_weight": new_spool_weight, "diameter": new_diameter,
"article_number": new_article_number, "weight": new_weight,
"comment": new_comment, "spool_weight": new_spool_weight,
"settings_extruder_temp": new_settings_extruder_temp, "article_number": new_article_number,
"settings_bed_temp": new_settings_bed_temp, "comment": new_comment,
"color_hex": new_color_hex, "settings_extruder_temp": new_settings_extruder_temp,
} "settings_bed_temp": new_settings_bed_temp,
"color_hex": new_color_hex,
},
)
# Clean up # Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()

View File

@@ -35,10 +35,9 @@ def test_get_all():
# Verify # Verify
settings = result.json() settings = result.json()
assert settings == { assert "currency" in settings
"currency": { assert settings["currency"] == {
"value": '"EUR"', "value": '"EUR"',
"is_set": False, "is_set": False,
"type": "string", "type": "string",
},
} }

View File

@@ -6,7 +6,7 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from ..conftest import length_from_weight # noqa: TID252 from ..conftest import assert_dicts_compatible, length_from_weight
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
@@ -50,22 +50,25 @@ def test_add_spool_remaining_weight(random_filament: dict[str, Any]):
) )
spool = result.json() spool = result.json()
assert spool == { assert_dicts_compatible(
"id": spool["id"], spool,
"registered": spool["registered"], {
"first_used": "2023-01-02T11:00:00Z", "id": spool["id"],
"last_used": "2023-01-02T11:00:00Z", "registered": spool["registered"],
"filament": random_filament, "first_used": "2023-01-02T11:00:00Z",
"remaining_weight": pytest.approx(remaining_weight), "last_used": "2023-01-02T11:00:00Z",
"used_weight": pytest.approx(used_weight), "filament": random_filament,
"remaining_length": pytest.approx(remaining_length), "remaining_weight": pytest.approx(remaining_weight),
"used_length": pytest.approx(used_length), "used_weight": pytest.approx(used_weight),
"location": location, "remaining_length": pytest.approx(remaining_length),
"lot_nr": lot_nr, "used_length": pytest.approx(used_length),
"comment": comment, "location": location,
"archived": archived, "lot_nr": lot_nr,
"price": price, "comment": comment,
} "archived": archived,
"price": price,
},
)
# Verify that registered happened almost now (within 1 minute) # Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds()) diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
@@ -114,21 +117,24 @@ def test_add_spool_used_weight(random_filament: dict[str, Any]):
) )
spool = result.json() spool = result.json()
assert spool == { assert_dicts_compatible(
"id": spool["id"], spool,
"registered": spool["registered"], {
"first_used": first_used, "id": spool["id"],
"last_used": last_used, "registered": spool["registered"],
"filament": random_filament, "first_used": first_used,
"remaining_weight": pytest.approx(remaining_weight), "last_used": last_used,
"used_weight": pytest.approx(used_weight), "filament": random_filament,
"remaining_length": pytest.approx(remaining_length), "remaining_weight": pytest.approx(remaining_weight),
"used_length": pytest.approx(used_length), "used_weight": pytest.approx(used_weight),
"location": location, "remaining_length": pytest.approx(remaining_length),
"lot_nr": lot_nr, "used_length": pytest.approx(used_length),
"comment": comment, "location": location,
"archived": archived, "lot_nr": lot_nr,
} "comment": comment,
"archived": archived,
},
)
# Clean up # Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
@@ -161,16 +167,19 @@ def test_add_spool_required(random_filament: dict[str, Any]):
) )
spool = result.json() spool = result.json()
assert spool == { assert_dicts_compatible(
"id": spool["id"], spool,
"registered": spool["registered"], {
"filament": random_filament, "id": spool["id"],
"used_weight": pytest.approx(used_weight), "registered": spool["registered"],
"remaining_weight": pytest.approx(remaining_weight), "filament": random_filament,
"used_length": pytest.approx(used_length), "used_weight": pytest.approx(used_weight),
"remaining_length": pytest.approx(remaining_length), "remaining_weight": pytest.approx(remaining_weight),
"archived": False, "used_length": pytest.approx(used_length),
} "remaining_length": pytest.approx(remaining_length),
"archived": False,
},
)
# Clean up # Clean up
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()

View File

@@ -7,14 +7,11 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from ..conftest import assert_lists_compatible
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
def spool_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
"""Compare two lists of spools where the order of the spools is not guaranteed."""
return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
@dataclass @dataclass
class Fixture: class Fixture:
spools: list[dict[str, Any]] spools: list[dict[str, Any]]
@@ -101,7 +98,10 @@ def test_find_all_spools(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3], spools.spools[4])) assert_lists_compatible(
spools_result,
(spools.spools[0], spools.spools[1], spools.spools[3], spools.spools[4]),
)
def test_find_all_spools_including_archived(spools: Fixture): def test_find_all_spools_including_archived(spools: Fixture):
@@ -111,7 +111,7 @@ def test_find_all_spools_including_archived(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal( assert_lists_compatible(
spools_result, spools_result,
( (
spools.spools[0], spools.spools[0],
@@ -272,7 +272,7 @@ def test_find_spools_by_filament_name(spools: Fixture, field_name: str):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_empty_filament_name(spools: Fixture): def test_find_spools_by_empty_filament_name(spools: Fixture):
@@ -285,7 +285,7 @@ def test_find_spools_by_empty_filament_name(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))
@pytest.mark.parametrize("field_name", ["filament_id", "filament.id"]) @pytest.mark.parametrize("field_name", ["filament_id", "filament.id"])
@@ -299,7 +299,7 @@ def test_find_spools_by_filament_id(spools: Fixture, field_name: str):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_multiple_filament_ids(spools: Fixture): def test_find_spools_by_multiple_filament_ids(spools: Fixture):
@@ -315,7 +315,7 @@ def test_find_spools_by_multiple_filament_ids(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3])) assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3]))
@pytest.mark.parametrize("field_name", ["filament_material", "filament.material"]) @pytest.mark.parametrize("field_name", ["filament_material", "filament.material"])
@@ -329,7 +329,7 @@ def test_find_spools_by_filament_material(spools: Fixture, field_name: str):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_empty_filament_material(spools: Fixture): def test_find_spools_by_empty_filament_material(spools: Fixture):
@@ -342,7 +342,7 @@ def test_find_spools_by_empty_filament_material(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))
@pytest.mark.parametrize("field_name", ["vendor_name", "filament.vendor.name"]) @pytest.mark.parametrize("field_name", ["vendor_name", "filament.vendor.name"])
@@ -356,7 +356,7 @@ def test_find_spools_by_filament_vendor_name(spools: Fixture, field_name: str):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_empty_filament_vendor_name(spools: Fixture): def test_find_spools_by_empty_filament_vendor_name(spools: Fixture):
@@ -383,7 +383,7 @@ def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[1]))
def test_find_spools_by_multiple_vendor_ids(spools: Fixture): def test_find_spools_by_multiple_vendor_ids(spools: Fixture):
@@ -403,7 +403,10 @@ def test_find_spools_by_multiple_vendor_ids(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert len(spools_result) == 4 assert len(spools_result) == 4
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[2], spools.spools[4])) assert_lists_compatible(
spools_result,
(spools.spools[0], spools.spools[1], spools.spools[2], spools.spools[4]),
)
def test_find_spools_by_empty_filament_vendor_id(spools: Fixture): def test_find_spools_by_empty_filament_vendor_id(spools: Fixture):
@@ -442,7 +445,7 @@ def test_find_spools_by_empty_location(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))
def test_find_spools_by_empty_and_filled_location(spools: Fixture): def test_find_spools_by_empty_and_filled_location(spools: Fixture):
@@ -455,7 +458,7 @@ def test_find_spools_by_empty_and_filled_location(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[3], spools.spools[4])) assert_lists_compatible(spools_result, (spools.spools[0], spools.spools[3], spools.spools[4]))
def test_find_spools_by_lot_nr(spools: Fixture): def test_find_spools_by_lot_nr(spools: Fixture):
@@ -481,4 +484,4 @@ def test_find_spools_by_empty_lot_nr(spools: Fixture):
# Verify # Verify
spools_result = result.json() spools_result = result.json()
assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) assert_lists_compatible(spools_result, (spools.spools[3], spools.spools[4]))

View File

@@ -5,7 +5,7 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from ..conftest import length_from_weight # noqa: TID252 from ..conftest import length_from_weight
URL = "http://spoolman:8000" URL = "http://spoolman:8000"

View File

@@ -4,6 +4,8 @@ from datetime import datetime, timezone
import httpx import httpx
from ..conftest import assert_dicts_compatible
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
@@ -20,12 +22,15 @@ def test_add_vendor():
# Verify # Verify
vendor = result.json() vendor = result.json()
assert vendor == { assert_dicts_compatible(
"id": vendor["id"], vendor,
"registered": vendor["registered"], {
"name": name, "id": vendor["id"],
"comment": comment, "registered": vendor["registered"],
} "name": name,
"comment": comment,
},
)
# Verify that registered happened almost now (within 1 minute) # Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(vendor["registered"])).total_seconds()) diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(vendor["registered"])).total_seconds())
@@ -47,11 +52,14 @@ def test_add_vendor_required():
# Verify # Verify
vendor = result.json() vendor = result.json()
assert vendor == { assert_dicts_compatible(
"id": vendor["id"], vendor,
"registered": vendor["registered"], {
"name": name, "id": vendor["id"],
} "registered": vendor["registered"],
"name": name,
},
)
# Clean up # Clean up
httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status()

View File

@@ -7,14 +7,11 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from ..conftest import assert_lists_compatible
URL = "http://spoolman:8000" URL = "http://spoolman:8000"
def vendor_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool:
"""Compare two lists of vendors where the order of the vendors is not guaranteed."""
return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"])
@dataclass @dataclass
class Fixture: class Fixture:
vendors: list[dict[str, Any]] vendors: list[dict[str, Any]]
@@ -62,7 +59,7 @@ def test_find_all_vendors(vendors: Fixture):
# Verify # Verify
vendors_result = result.json() vendors_result = result.json()
assert vendor_lists_equal(vendors_result, vendors.vendors) assert_lists_compatible(vendors_result, vendors.vendors)
def test_find_all_vendors_sort_asc(vendors: Fixture): def test_find_all_vendors_sort_asc(vendors: Fixture):