Merge pull request #256 from Donkie/custom-fields
New backend custom fields system
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
33
client/src/components/dateTimePicker.tsx
Normal file
33
client/src/components/dateTimePicker.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
client/src/components/extraFields.tsx
Normal file
211
client/src/components/extraFields.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
57
client/src/components/inputNumberRange.tsx
Normal file
57
client/src/components/inputNumberRange.tsx
Normal 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]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
])}
|
])}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
636
client/src/pages/settings/extraFieldsSettings.tsx
Normal file
636
client/src/pages/settings/extraFieldsSettings.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
client/src/pages/settings/generalSettings.tsx
Normal file
79
client/src/pages/settings/generalSettings.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
client/src/pages/settings/index.tsx
Normal file
99
client/src/pages/settings/index.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
41
client/src/pages/vendors/create.tsx
vendored
41
client/src/pages/vendors/create.tsx
vendored
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
39
client/src/pages/vendors/edit.tsx
vendored
39
client/src/pages/vendors/edit.tsx
vendored
@@ -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>
|
||||||
|
|||||||
57
client/src/pages/vendors/list.tsx
vendored
57
client/src/pages/vendors/list.tsx
vendored
@@ -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),
|
||||||
])}
|
])}
|
||||||
|
|||||||
4
client/src/pages/vendors/model.tsx
vendored
4
client/src/pages/vendors/model.tsx
vendored
@@ -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 } };
|
||||||
|
|||||||
7
client/src/pages/vendors/show.tsx
vendored
7
client/src/pages/vendors/show.tsx
vendored
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
132
client/src/utils/queryFields.ts
Normal file
132
client/src/utils/queryFields.ts
Normal 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]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
61
client/src/utils/querySettings.ts
Normal file
61
client/src/utils/querySettings.ts
Normal 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]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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
98
spoolman/api/v1/field.py
Normal 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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
246
spoolman/extra_fields.py
Normal 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
|
||||||
@@ -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([]))
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
1
tests_integration/tests/fields/__init__.py
Normal file
1
tests_integration/tests/fields/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Integration tests for the custom extra fields system."""
|
||||||
429
tests_integration/tests/fields/test_create.py
Normal file
429
tests_integration/tests/fields/test_create.py
Normal 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)
|
||||||
31
tests_integration/tests/fields/test_delete.py
Normal file
31
tests_integration/tests/fields/test_delete.py
Normal 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() == []
|
||||||
60
tests_integration/tests/fields/test_get.py
Normal file
60
tests_integration/tests/fields/test_get.py
Normal 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)
|
||||||
156
tests_integration/tests/fields/test_utilize.py
Normal file
156
tests_integration/tests/fields/test_utilize.py
Normal 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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
30
tests_integration/tests/vendor/test_add.py
vendored
30
tests_integration/tests/vendor/test_add.py
vendored
@@ -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()
|
||||||
|
|||||||
9
tests_integration/tests/vendor/test_find.py
vendored
9
tests_integration/tests/vendor/test_find.py
vendored
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user