diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 1386cb4..21c1326 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -264,7 +264,7 @@
},
"extra_fields": {
"tab": "Extra Fields",
- "description": "
Here you can add extra custom fields to your entities.
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.
Default value is only applied to new items.
Extra fields can not be sorted or filtered in the table views.
",
+ "description": "Here you can add extra custom fields to your entities.
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.
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.
Extra fields can not be sorted or filtered in the table views.
",
"params": {
"key": "Key",
"name": "Name",
diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx
index 53f89a7..a773e3d 100644
--- a/client/src/components/column.tsx
+++ b/client/src/components/column.tsx
@@ -3,7 +3,7 @@ import { ColumnFilterItem, ColumnType } from "antd/es/table/interface";
import { getFiltersForField, typeFilters } from "../utils/filtering";
import { TableState } from "../utils/saveload";
import { getSortOrderForField, typeSorters } from "../utils/sorting";
-import { NumberFieldUnit } from "./numberField";
+import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { DateField, TextField } from "@refinedev/antd";
@@ -12,7 +12,8 @@ import SpoolIcon from "../icon_spool.svg?react";
import { useTranslate } from "@refinedev/core";
import { enrichText } from "../utils/parsing";
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);
@@ -39,14 +40,19 @@ export interface Action {
}
interface BaseColumnProps {
- id: keyof Obj & string;
+ id: string | string[];
dataId?: keyof Obj & string;
i18ncat?: string;
i18nkey?: string;
+ title?: string;
+ sorter?: boolean;
+ t: (key: string) => string;
+ navigate: (link: string) => void;
dataSource: Obj[];
tableState: TableState;
width?: number;
actions?: (record: Obj) => Action[];
+ transform?: (value: unknown) => unknown;
}
interface FilteredColumnProps {
@@ -70,24 +76,32 @@ interface CustomColumnProps {
function Column(
props: BaseColumnProps & FilteredColumnProps & CustomColumnProps
): ColumnType | undefined {
- const t = useTranslate();
- const navigate = useNavigate();
+ const t = props.t;
+ const navigate = props.navigate;
// 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;
}
const columnProps: ColumnType = {
dataIndex: props.id,
- title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
- sorter: true,
- sortOrder: getSortOrderForField(typeSorters(props.tableState.sorters), props.dataId ?? props.id),
+ title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
filterMultiple: props.allowMultipleFilters ?? true,
width: props.width ?? undefined,
onCell: props.onCell ?? undefined,
};
+ // Sorting
+ if (props.sorter) {
+ columnProps.sorter = true;
+ columnProps.sortOrder = getSortOrderForField(
+ typeSorters(props.tableState.sorters),
+ props.dataId ?? (props.id as keyof Obj)
+ );
+ }
+
// Filter
if (props.filters && props.filteredValue) {
columnProps.filters = props.filters;
@@ -106,7 +120,12 @@ function Column(
}
// 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) => {
if (!props.actions) {
return render(value, record, index);
@@ -144,13 +163,19 @@ function Column(
}
export function SortedColumn(props: BaseColumnProps) {
- return Column(props);
-}
-
-export function RichColumn(props: BaseColumnProps) {
return Column({
...props,
- render: (value: string | undefined) => {
+ sorter: true,
+ });
+}
+
+export function RichColumn(
+ props: Omit, "transform"> & { transform?: (value: unknown) => string }
+) {
+ return Column({
+ ...props,
+ render: (rawValue: string | undefined) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
return enrichText(value);
},
});
@@ -182,7 +207,7 @@ export function FilteredQueryColumn(props: FilteredQueryColu
});
const typedFilters = typeFilters(props.tableState.filters);
- const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
+ const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
@@ -193,14 +218,16 @@ export function FilteredQueryColumn(props: FilteredQueryColu
interface NumberColumnProps extends BaseColumnProps {
unit: string;
- decimals?: number;
+ maxDecimals?: number;
+ minDecimals?: number;
defaultText?: string;
}
export function NumberColumn(props: NumberColumnProps) {
return Column({
...props,
- render: (value) => {
+ render: (rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
return ;
}
@@ -209,8 +236,8 @@ export function NumberColumn(props: NumberColumnProps)
value={value}
unit={props.unit}
options={{
- maximumFractionDigits: props.decimals ?? 0,
- minimumFractionDigits: props.decimals ?? 0,
+ maximumFractionDigits: props.maxDecimals ?? 0,
+ minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0,
}}
/>
);
@@ -221,7 +248,8 @@ export function NumberColumn(props: NumberColumnProps)
export function DateColumn(props: BaseColumnProps) {
return Column({
...props,
- render: (value) => {
+ render: (rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
return (
(props: SpoolIconColumnProps<
});
const typedFilters = typeFilters(props.tableState.filters);
- const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id);
+ const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
@@ -312,7 +340,8 @@ export function SpoolIconColumn(props: SpoolIconColumnProps<
},
};
},
- render: (value, record: Obj) => {
+ render: (rawValue, record: Obj) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
const colorStr = props.color(record);
return (
@@ -334,3 +363,121 @@ export function SpoolIconColumn(props: SpoolIconColumnProps<
},
});
}
+
+export function NumberRangeColumn(props: NumberColumnProps) {
+ return Column({
+ ...props,
+ render: (rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
+ if (value === null || value === undefined) {
+ return ;
+ }
+ if (!Array.isArray(value) || value.length !== 2) {
+ return ;
+ }
+
+ return (
+
+ );
+ },
+ });
+}
+
+export function CustomFieldColumn(props: Omit, "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 ;
+ },
+ });
+ } else if (field.field_type === FieldType.choice && !field.multi_choice) {
+ return Column({
+ ...commonProps,
+ render: (rawValue) => {
+ const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
+ return ;
+ },
+ });
+ } else if (field.field_type === FieldType.choice && field.multi_choice) {
+ return Column({
+ ...commonProps,
+ render: (rawValue) => {
+ const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
+ return ;
+ },
+ });
+ } else {
+ return Column({
+ ...commonProps,
+ render: (rawValue) => {
+ const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
+ return ;
+ },
+ });
+ }
+}
diff --git a/client/src/components/dateTimePicker.tsx b/client/src/components/dateTimePicker.tsx
new file mode 100644
index 0000000..4097682
--- /dev/null
+++ b/client/src/components/dateTimePicker.tsx
@@ -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(props: { value?: T; onChange?: (value?: T) => void }) {
+ return (
+ {
+ if (value) {
+ if (typeof props.value === "string") {
+ props.onChange?.(value.toISOString() as T);
+ } else {
+ props.onChange?.(value as T);
+ }
+ } else {
+ props.onChange?.(undefined);
+ }
+ }}
+ />
+ );
+}
diff --git a/client/src/components/extraFields.tsx b/client/src/components/extraFields.tsx
new file mode 100644
index 0000000..ab2bacc
--- /dev/null
+++ b/client/src/components/extraFields.tsx
@@ -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 = (
+
+ );
+ } else if (field.field_type === FieldType.float) {
+ item = (
+
+ );
+ } else if (field.field_type === FieldType.integer_range || field.field_type === FieldType.float_range) {
+ if (!Array.isArray(parsedValue) || parsedValue.length !== 2) {
+ return ;
+ }
+ item = (
+
+ );
+ } else if (field.field_type === FieldType.text) {
+ item = ;
+ } else if (field.field_type === FieldType.datetime) {
+ item = (
+
+ );
+ } else if (field.field_type === FieldType.boolean) {
+ item = ;
+ } else if (field.field_type === FieldType.choice && !field.multi_choice) {
+ item = ;
+ } else if (field.field_type === FieldType.choice && field.multi_choice) {
+ item = ;
+ } else {
+ throw new Error(`Unknown field type: ${field.field_type}`);
+ }
+ } else {
+ item = <>>;
+ }
+
+ return (
+ <>
+ {field.name}
+ {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 = ;
+ rules.push({
+ type: "integer",
+ });
+ } else if (field.field_type === FieldType.float) {
+ inputNode = ;
+ rules.push({
+ type: "number",
+ });
+ } else if (field.field_type === FieldType.integer_range) {
+ inputNode = ;
+ } else if (field.field_type === FieldType.float_range) {
+ inputNode = ;
+ } else if (field.field_type === FieldType.text) {
+ inputNode = ;
+ rules.push({
+ type: "string",
+ });
+ } else if (field.field_type === FieldType.datetime) {
+ inputNode = ;
+ } else if (field.field_type === FieldType.boolean) {
+ inputNode = ;
+ formItemProps.valuePropName = "checked";
+ rules.push({
+ type: "boolean",
+ });
+ } else if (field.field_type === FieldType.choice) {
+ inputNode = (
+ ({ 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 (
+
+ {inputNode}
+
+ );
+}
+
+/**
+ * Convert the string-based value extra key-values of an entity to their JSON-parsed values.
+ * @param obj
+ * @returns
+ */
+export function ParsedExtras(
+ obj: T
+): Omit & { 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(
+ obj: T
+): Omit & { 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,
+ };
+ }
+}
diff --git a/client/src/components/inputNumberRange.tsx b/client/src/components/inputNumberRange.tsx
new file mode 100644
index 0000000..23da031
--- /dev/null
+++ b/client/src/components/inputNumberRange.tsx
@@ -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 (
+ <>
+ {
+ if (props.onChange) {
+ props.onChange([value, max]);
+ }
+ }}
+ />
+ {" \u2013 "}
+ {
+ if (props.onChange) {
+ props.onChange([min, value]);
+ }
+ }}
+ />
+ >
+ );
+}
diff --git a/client/src/components/numberField.tsx b/client/src/components/numberField.tsx
index b8bf0da..facb258 100644
--- a/client/src/components/numberField.tsx
+++ b/client/src/components/numberField.tsx
@@ -26,3 +26,36 @@ export const NumberFieldUnit: React.FC = ({ value, locale, options, ...re
);
};
+
+/**
+ * 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 ? <>> : }
+ {" \u2013 "}
+ {max === null ? <>> : }
+ >
+ );
+}
diff --git a/client/src/pages/filaments/create.tsx b/client/src/pages/filaments/create.tsx
index 4ce1813..58e8a46 100644
--- a/client/src/pages/filaments/create.tsx
+++ b/client/src/pages/filaments/create.tsx
@@ -1,11 +1,20 @@
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 { Form, Input, Select, InputNumber, ColorPicker, Button } from "antd";
import TextArea from "antd/es/input/TextArea";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { IVendor } from "../vendors/model";
import { IFilament } from "./model";
+import { EntityType, FieldType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+
+dayjs.extend(utc);
+
+// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
+type IFilamentParsedExtras = Omit & { extra?: { [key: string]: unknown } };
interface CreateOrCloneProps {
mode: "create" | "clone";
@@ -13,10 +22,20 @@ interface CreateOrCloneProps {
export const FilamentCreate: React.FC = (props) => {
const t = useTranslate();
+ const extraFields = useGetFields(EntityType.filament);
- const { form, formProps, formLoading, onFinish, redirect } = useForm();
+ 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
if (formProps.initialValues.vendor) {
formProps.initialValues.vendor_id = formProps.initialValues.vendor.id;
@@ -24,7 +43,7 @@ export const FilamentCreate: React.FC {
- const values = await form.validateFields();
+ const values = StringifiedExtras(await form.validateFields());
await onFinish(values);
redirect(redirectTo, (values as IFilament).id);
};
@@ -34,6 +53,21 @@ export const FilamentCreate: React.FC {
+ extraFields.data?.forEach((field) => {
+ if (formProps.initialValues && field.default_value) {
+ let parsedValue = JSON.parse(field.default_value as string);
+ if (field.field_type === FieldType.datetime) {
+ parsedValue = dayjs(parsedValue);
+ }
+
+ form.setFieldsValue({ extra: { [field.key]: parsedValue } });
+ }
+ });
+ }, [form, extraFields.data, formProps.initialValues]);
+
return (
+ {extraFields.data?.map((field, index) => (
+
+ ))}
);
diff --git a/client/src/pages/filaments/edit.tsx b/client/src/pages/filaments/edit.tsx
index 8b44a6f..ba77591 100644
--- a/client/src/pages/filaments/edit.tsx
+++ b/client/src/pages/filaments/edit.tsx
@@ -1,5 +1,5 @@
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 { Form, Input, DatePicker, Select, InputNumber, ColorPicker, message, Alert } from "antd";
import dayjs from "dayjs";
@@ -7,30 +7,62 @@ import TextArea from "antd/es/input/TextArea";
import { numberFormatter, numberParser } from "../../utils/parsing";
import { IVendor } from "../vendors/model";
import { IFilament } from "./model";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldFormItem, StringifiedExtras } from "../../components/extraFields";
+import { ParsedExtras } from "../../components/extraFields";
+
+// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
+type IFilamentParsedExtras = Omit & { extra?: { [key: string]: unknown } };
+
+/*
+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 = () => {
const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false);
+ const extraFields = useGetFields(EntityType.filament);
- const { formProps, saveButtonProps } = useForm({
+ const { formProps, saveButtonProps } = useForm({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened
messageApi.warning(t("filament.form.filament_updated"));
setHasChanged(true);
},
+ queryOptions: {
+ select(data) {
+ return {
+ data: ParsedExtras(data.data),
+ };
+ },
+ },
});
+ // Get vendor selection options
const { selectProps } = useSelect({
resource: "vendor",
optionLabel: "name",
});
+ // Add the vendor_id field to the form
if (formProps.initialValues) {
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 (
{contextHolder}
@@ -246,6 +278,9 @@ export const FilamentEdit: React.FC = () => {
>
+ {extraFields.data?.map((field, index) => (
+
+ ))}
{hasChanged && }
diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx
index 09b1356..c4a27c7 100644
--- a/client/src/pages/filaments/list.tsx
+++ b/client/src/pages/filaments/list.tsx
@@ -15,6 +15,7 @@ import {
SortedColumn,
SpoolIconColumn,
ActionsColumn,
+ CustomFieldColumn,
} from "../../components/column";
import {
useSpoolmanArticleNumbers,
@@ -24,9 +25,13 @@ import {
} from "../../components/otherModels";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { useNavigate } from "react-router-dom";
dayjs.extend(utc);
+// Extra is a key-value dict
+
interface IFilamentCollapsed extends Omit {
"vendor.name": string | null;
}
@@ -71,6 +76,10 @@ const defaultColumns = allColumns.filter(
export const FilamentList: React.FC = () => {
const t = useTranslate();
const invalidate = useInvalidate();
+ const navigate = useNavigate();
+ const extraFields = useGetFields(EntityType.filament);
+
+ const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
// Load initial state
const initialState = useInitialTableState(namespace);
@@ -145,6 +154,15 @@ export const FilamentList: React.FC = () => {
{ name: t("buttons.clone"), icon: , link: cloneUrl("filament", record.id) },
];
+ const commonProps = {
+ t,
+ navigate,
+ actions,
+ dataSource,
+ tableState,
+ sorter: true,
+ };
+
return (
(
@@ -163,10 +181,20 @@ export const FilamentList: React.FC = () => {
({
- key: column_id,
- label: t(translateColumnI18nKey(column_id)),
- })),
+ items: allColumnsWithExtraFields.map((column_id) => {
+ if (column_id.indexOf("extra.") === 0) {
+ 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,
selectable: true,
multiple: true,
@@ -195,129 +223,107 @@ export const FilamentList: React.FC = () => {
rowKey="id"
columns={removeUndefined([
SortedColumn({
+ ...commonProps,
id: "id",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
width: 70,
}),
FilteredQueryColumn({
+ ...commonProps,
id: "vendor.name",
i18nkey: "filament.fields.vendor_name",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanVendors(),
}),
SpoolIconColumn({
+ ...commonProps,
id: "name",
i18ncat: "filament",
color: (record: IFilamentCollapsed) => record.color_hex,
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanFilamentNames(),
}),
FilteredQueryColumn({
+ ...commonProps,
id: "material",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanMaterials(),
width: 110,
}),
SortedColumn({
+ ...commonProps,
id: "price",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
width: 80,
}),
NumberColumn({
+ ...commonProps,
id: "density",
i18ncat: "filament",
unit: "g/cm³",
- decimals: 2,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 2,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "diameter",
i18ncat: "filament",
unit: "mm",
- decimals: 2,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 2,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "weight",
i18ncat: "filament",
unit: "g",
- decimals: 1,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 1,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "spool_weight",
i18ncat: "filament",
unit: "g",
- decimals: 1,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 1,
width: 100,
}),
FilteredQueryColumn({
+ ...commonProps,
id: "article_number",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
filterValueQuery: useSpoolmanArticleNumbers(),
width: 130,
}),
NumberColumn({
+ ...commonProps,
id: "settings_extruder_temp",
i18ncat: "filament",
unit: "°C",
- decimals: 0,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 0,
width: 100,
}),
NumberColumn({
+ ...commonProps,
id: "settings_bed_temp",
i18ncat: "filament",
unit: "°C",
- decimals: 0,
- actions,
- dataSource,
- tableState,
+ maxDecimals: 0,
width: 100,
}),
DateColumn({
+ ...commonProps,
id: "registered",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
}),
+ ...(extraFields.data?.map((field) => {
+ return CustomFieldColumn({
+ ...commonProps,
+ field,
+ });
+ }) ?? []),
RichColumn({
+ ...commonProps,
id: "comment",
i18ncat: "filament",
- actions,
- dataSource,
- tableState,
width: 150,
}),
ActionsColumn(actions),
diff --git a/client/src/pages/filaments/model.tsx b/client/src/pages/filaments/model.tsx
index 4c3207c..3558c94 100644
--- a/client/src/pages/filaments/model.tsx
+++ b/client/src/pages/filaments/model.tsx
@@ -16,4 +16,5 @@ export interface IFilament {
settings_extruder_temp?: number;
settings_bed_temp?: number;
color_hex?: string;
+ extra: { [key: string]: string };
}
diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx
index e7ef2fd..ecb780f 100644
--- a/client/src/pages/filaments/show.tsx
+++ b/client/src/pages/filaments/show.tsx
@@ -8,6 +8,8 @@ import utc from "dayjs/plugin/utc";
import { IFilament } from "./model";
import { enrichText } from "../../utils/parsing";
import { useNavigate } from "react-router-dom";
+import { EntityType, useGetFields } from "../../utils/queryFields";
+import { ExtraFieldDisplay } from "../../components/extraFields";
dayjs.extend(utc);
const { Title } = Typography;
@@ -15,6 +17,7 @@ const { Title } = Typography;
export const FilamentShow: React.FC = () => {
const t = useTranslate();
const navigate = useNavigate();
+ const extraFields = useGetFields(EntityType.filament);
const { queryResult } = useShow({
liveMode: "auto",
});
@@ -74,8 +77,6 @@ export const FilamentShow: React.FC = () => {
/>
{t("filament.fields.name")}
- {/* {t("filament.fields.id")}
- {vendorIsLoading ? <>Loading...> : <>{vendorData?.data?.id}>} */}
{t("filament.fields.color_hex")}
{t("filament.fields.material")}
@@ -134,6 +135,9 @@ export const FilamentShow: React.FC = () => {
{t("filament.fields.comment")}
+ {extraFields?.data?.map((field, index) => (
+
+ ))}
);
};
diff --git a/client/src/pages/settings/ExtraFieldsSettings.tsx b/client/src/pages/settings/ExtraFieldsSettings.tsx
index 2efe9b3..7528a8f 100644
--- a/client/src/pages/settings/ExtraFieldsSettings.tsx
+++ b/client/src/pages/settings/ExtraFieldsSettings.tsx
@@ -1,7 +1,6 @@
import {
Button,
Checkbox,
- DatePicker,
Flex,
Form,
FormInstance,
@@ -13,7 +12,7 @@ import {
Table,
message,
} from "antd";
-import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "./queryFields";
+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";
@@ -25,13 +24,15 @@ 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 z";
+const dateTimeFormat = "YYYY-MM-DD HH:mm:ss";
interface EditableCellProps extends React.HTMLAttributes {
record: FieldHolder;
@@ -194,19 +195,19 @@ const EditableCell: React.FC = ({ record, editing, dataIndex,
type: "number",
});
} else if (fieldType === FieldType.integer_range) {
- inputNode = ;
+ inputNode = ;
rules.push({
- type: "string",
- pattern: /^-?\d+\s*-\s*-?\d+$/,
+ type: "array",
+ len: 2,
});
} else if (fieldType === FieldType.float_range) {
- inputNode = ;
+ inputNode = ;
rules.push({
- type: "string",
- pattern: /^-?\d+([.,]\d+)?\s*-\s*-?\d+([.,]\d+)?$/,
+ type: "array",
+ len: 2,
});
} else if (fieldType === FieldType.datetime) {
- inputNode = ;
+ inputNode = ;
} else if (fieldType === FieldType.choice) {
inputNode = (
& { key: React.Key }) => {
const values = { ...record };
- console.log(values);
+
if (values.default_value && typeof values.default_value === "string") {
const def = JSON.parse(values.default_value);
- if (Array.isArray(def)) {
- if (values.field_type === FieldType.choice) {
- values.default_value = def.join(", ");
- } else if (values.field_type === FieldType.integer_range || values.field_type === FieldType.float_range) {
- values.default_value = `${def[0]} - ${def[1]}`;
- } else {
- values.default_value = undefined;
- }
- } else if (values.field_type === FieldType.datetime) {
- // Parse as dayjs
- values.default_value = dayjs(def);
- } else if (values.field_type === FieldType.boolean) {
+ if (values.field_type === FieldType.boolean) {
values.default_value = def ? true : false;
- } else if (values.field_type === FieldType.text) {
+ } else {
values.default_value = def;
}
} else {
@@ -383,24 +373,16 @@ export function ExtraFieldsSettings() {
// Do some value conversions
try {
// Convert float and integer range to array using the validation regex
- if (updatedField.field_type === FieldType.float_range && typeof updatedField.default_value === "string") {
- const pattern = /^(-?\d+(?:[.,]\d+)?)\s*-\s*(-?\d+(?:[.,]\d+)?)$/;
- const matches = updatedField.default_value.match(pattern);
- if (matches) {
- const val1 = parseFloat(matches[1].replace(",", "."));
- const val2 = parseFloat(matches[2].replace(",", "."));
- updatedField.default_value = JSON.stringify([Math.min(val1, val2), Math.max(val1, val2)]);
- }
- } else if (
- updatedField.field_type === FieldType.integer_range &&
- typeof updatedField.default_value === "string"
+ if (
+ (updatedField.field_type === FieldType.float_range || updatedField.field_type === FieldType.integer_range) &&
+ updatedField.default_value !== undefined
) {
- const pattern = /^(-?\d+)\s*-\s*(-?\d+)$/;
- const matches = updatedField.default_value.match(pattern);
- if (matches) {
- const val1 = parseInt(matches[1]);
- const val2 = parseInt(matches[2]);
- updatedField.default_value = JSON.stringify([Math.min(val1, val2), Math.max(val1, val2)]);
+ 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
@@ -441,8 +423,6 @@ export function ExtraFieldsSettings() {
// Submit it!
try {
- console.log(updatedField);
-
setIsSubmitting(true);
setField.reset();
@@ -511,7 +491,7 @@ export function ExtraFieldsSettings() {
(Array.isArray(val) && record.field.field_type === FieldType.integer_range) ||
record.field.field_type === FieldType.float_range
) {
- return `${val[0]} - ${val[1]}`;
+ return `${val[0]} \u2013 ${val[1]}`;
} else {
return null;
}
diff --git a/client/src/pages/settings/GeneralSettings.tsx b/client/src/pages/settings/GeneralSettings.tsx
index 48ca193..c8c2f42 100644
--- a/client/src/pages/settings/GeneralSettings.tsx
+++ b/client/src/pages/settings/GeneralSettings.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { useTranslate } from "@refinedev/core";
import { Button, Form, Input, message } from "antd";
-import { useGetSettings, useSetSetting } from "./querySettings";
+import { useGetSettings, useSetSetting } from "../../utils/querySettings";
export function GeneralSettings() {
const settings = useGetSettings();
diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx
index 714a6f8..eda7e16 100644
--- a/client/src/pages/spools/list.tsx
+++ b/client/src/pages/spools/list.tsx
@@ -52,7 +52,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
} else {
filament_name = element.filament.name ?? element.filament.id.toString();
}
- if (!element.price){
+ if (!element.price) {
element.price = element.filament.price;
}
return {
@@ -306,6 +306,7 @@ export const SpoolList: React.FC = () => {
tableState,
dataId: "filament.id",
filterValueQuery: useSpoolmanFilamentFilter(),
+ sorter: true,
}),
FilteredQueryColumn({
id: "filament.material",
@@ -315,6 +316,7 @@ export const SpoolList: React.FC = () => {
tableState,
filterValueQuery: useSpoolmanMaterials(),
width: 120,
+ sorter: true,
}),
SortedColumn({
id: "price",
@@ -328,43 +330,47 @@ export const SpoolList: React.FC = () => {
id: "used_weight",
i18ncat: "spool",
unit: "g",
- decimals: 1,
+ maxDecimals: 1,
actions,
dataSource,
tableState,
width: 110,
+ sorter: true,
}),
NumberColumn({
id: "remaining_weight",
i18ncat: "spool",
unit: "g",
- decimals: 1,
+ maxDecimals: 1,
defaultText: t("unknown"),
actions,
dataSource,
tableState,
width: 110,
+ sorter: true,
}),
NumberColumn({
id: "used_length",
i18ncat: "spool",
unit: "mm",
- decimals: 1,
+ maxDecimals: 1,
actions,
dataSource,
tableState,
width: 120,
+ sorter: true,
}),
NumberColumn({
id: "remaining_length",
i18ncat: "spool",
unit: "mm",
- decimals: 1,
+ maxDecimals: 1,
defaultText: t("unknown"),
actions,
dataSource,
tableState,
width: 120,
+ sorter: true,
}),
FilteredQueryColumn({
id: "location",
@@ -374,6 +380,7 @@ export const SpoolList: React.FC = () => {
tableState,
filterValueQuery: useSpoolmanLocations(),
width: 120,
+ sorter: true,
}),
FilteredQueryColumn({
id: "lot_nr",
@@ -383,6 +390,7 @@ export const SpoolList: React.FC = () => {
tableState,
filterValueQuery: useSpoolmanLotNumbers(),
width: 120,
+ sorter: true,
}),
DateColumn({
id: "first_used",
@@ -390,6 +398,7 @@ export const SpoolList: React.FC = () => {
actions,
dataSource,
tableState,
+ sorter: true,
}),
DateColumn({
id: "last_used",
@@ -397,6 +406,7 @@ export const SpoolList: React.FC = () => {
actions,
dataSource,
tableState,
+ sorter: true,
}),
DateColumn({
id: "registered",
@@ -404,6 +414,7 @@ export const SpoolList: React.FC = () => {
actions,
dataSource,
tableState,
+ sorter: true,
}),
RichColumn({
id: "comment",
@@ -412,6 +423,7 @@ export const SpoolList: React.FC = () => {
dataSource,
tableState,
width: 150,
+ sorter: true,
}),
ActionsColumn(actions),
])}
diff --git a/client/src/pages/vendors/list.tsx b/client/src/pages/vendors/list.tsx
index e8615c2..50a87cb 100644
--- a/client/src/pages/vendors/list.tsx
+++ b/client/src/pages/vendors/list.tsx
@@ -152,6 +152,7 @@ export const VendorList: React.FC = () => {
actions,
dataSource,
tableState,
+ sorter: true,
}),
RichColumn({
id: "comment",
@@ -159,6 +160,7 @@ export const VendorList: React.FC = () => {
actions,
dataSource,
tableState,
+ sorter: true,
}),
ActionsColumn(actions),
])}
diff --git a/client/src/pages/settings/queryFields.ts b/client/src/utils/queryFields.ts
similarity index 98%
rename from client/src/pages/settings/queryFields.ts
rename to client/src/utils/queryFields.ts
index 8ea0a43..d569278 100644
--- a/client/src/pages/settings/queryFields.ts
+++ b/client/src/utils/queryFields.ts
@@ -23,7 +23,7 @@ export interface FieldParameters {
order: number;
unit?: string;
field_type: FieldType;
- default_value?: string | boolean | dayjs.Dayjs;
+ default_value?: string | (number | null)[] | boolean | dayjs.Dayjs;
choices?: string[];
multi_choice?: boolean;
}
diff --git a/client/src/pages/settings/querySettings.ts b/client/src/utils/querySettings.ts
similarity index 100%
rename from client/src/pages/settings/querySettings.ts
rename to client/src/utils/querySettings.ts
diff --git a/spoolman/extra_fields.py b/spoolman/extra_fields.py
index adb3847..ba773cc 100644
--- a/spoolman/extra_fields.py
+++ b/spoolman/extra_fields.py
@@ -73,8 +73,8 @@ def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None:
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) for value in data):
- raise ValueError("Value list must contain only integers.")
+ 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.")
@@ -83,8 +83,10 @@ def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None:
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)) and not isinstance(value, bool) for value in data):
- raise ValueError("Value list must contain only floats.")
+ 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.")