Extra fields now finished for filaments

This commit is contained in:
Donkie
2024-01-23 18:58:04 +01:00
parent 23818c1005
commit fb23024ed0
18 changed files with 699 additions and 139 deletions

View File

@@ -264,7 +264,7 @@
},
"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>Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
"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",

View File

@@ -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<Obj extends Entity> {
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<Obj> {
function Column<Obj extends Entity>(
props: BaseColumnProps<Obj> & FilteredColumnProps & CustomColumnProps<Obj>
): ColumnType<Obj> | 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<Obj> = {
dataIndex: props.id,
title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`),
sorter: true,
sortOrder: getSortOrderForField(typeSorters<Obj>(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<Obj>(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<Obj extends Entity>(
}
// 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<Obj extends Entity>(
}
export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column(props);
}
export function RichColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({
...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);
},
});
@@ -182,7 +207,7 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
});
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 = () => {
query.refetch();
@@ -193,14 +218,16 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
unit: string;
decimals?: number;
maxDecimals?: number;
minDecimals?: number;
defaultText?: string;
}
export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
return Column({
...props,
render: (value) => {
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
return <TextField value={props.defaultText ?? ""} />;
}
@@ -209,8 +236,8 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
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<Obj extends Entity>(props: NumberColumnProps<Obj>)
export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({
...props,
render: (value) => {
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return (
<DateField
hidden={!value}
@@ -291,7 +319,7 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
});
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 = () => {
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);
return (
<Row wrap={false} justify="space-around" align="middle">
@@ -334,3 +363,121 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
},
});
}
export function NumberRangeColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
return Column({
...props,
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
return <TextField value={props.defaultText ?? ""} />;
}
if (!Array.isArray(value) || value.length !== 2) {
return <TextField value={props.defaultText ?? ""} />;
}
return (
<NumberFieldUnitRange
value={value}
unit={props.unit}
options={{
maximumFractionDigits: props.maxDecimals ?? 0,
minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0,
}}
/>
);
},
});
}
export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProps<Obj>, "id"> & { field: Field }) {
const field = props.field;
const commonProps = {
...props,
id: ["extra", field.key],
title: field.name,
sorter: false,
transform: (value: unknown) => {
if (value === null || value === undefined) {
return undefined;
}
return JSON.parse(value as string);
},
};
if (field.field_type === FieldType.integer) {
return NumberColumn({
...commonProps,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float) {
return NumberColumn({
...commonProps,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.integer_range) {
return NumberRangeColumn({
...commonProps,
unit: field.unit ?? "",
maxDecimals: 0,
});
} else if (field.field_type === FieldType.float_range) {
return NumberRangeColumn({
...commonProps,
unit: field.unit ?? "",
minDecimals: 0,
maxDecimals: 3,
});
} else if (field.field_type === FieldType.text) {
return RichColumn({
...commonProps,
});
} else if (field.field_type === FieldType.datetime) {
return DateColumn({
...commonProps,
});
} else if (field.field_type === FieldType.boolean) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
let text;
if (value === undefined || value === null) {
text = "";
} else if (value) {
text = props.t("yes");
} else {
text = props.t("no");
}
return <TextField value={text} />;
},
});
} else if (field.field_type === FieldType.choice && !field.multi_choice) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={value} />;
},
});
} else if (field.field_type === FieldType.choice && field.multi_choice) {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={(value as string[] | undefined)?.join(", ")} />;
},
});
} else {
return Column({
...commonProps,
render: (rawValue) => {
const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue;
return <TextField value={value} />;
},
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<IFilament, "extra"> & { extra?: { [key: string]: unknown } };
interface CreateOrCloneProps {
mode: "create" | "clone";
@@ -13,10 +22,20 @@ interface CreateOrCloneProps {
export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps> = (props) => {
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
if (formProps.initialValues.vendor) {
formProps.initialValues.vendor_id = formProps.initialValues.vendor.id;
@@ -24,7 +43,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
}
const handleSubmit = async (redirectTo: "list" | "edit" | "create") => {
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<IResourceComponentsProps & CreateOrClonePr
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) {
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 (
<Create
title={props.mode === "create" ? t("filament.titles.create") : t("filament.titles.clone")}
@@ -229,6 +263,9 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
>
<TextArea maxLength={1024} />
</Form.Item>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form>
</Create>
);

View File

@@ -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<IFilament, "extra"> & { 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<IResourceComponentsProps> = () => {
const t = useTranslate();
const [messageApi, contextHolder] = message.useMessage();
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",
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<IVendor>({
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 (
<Edit saveButtonProps={saveButtonProps}>
{contextHolder}
@@ -246,6 +278,9 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
>
<TextArea maxLength={1024} />
</Form.Item>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form>
{hasChanged && <Alert description={t("filament.form.filament_updated")} type="warning" showIcon />}
</Edit>

View File

@@ -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<IFilament, "vendor"> {
"vendor.name": string | null;
}
@@ -71,6 +76,10 @@ const defaultColumns = allColumns.filter(
export const FilamentList: React.FC<IResourceComponentsProps> = () => {
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<IResourceComponentsProps> = () => {
{ name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("filament", record.id) },
];
const commonProps = {
t,
navigate,
actions,
dataSource,
tableState,
sorter: true,
};
return (
<List
headerButtons={({ defaultButtons }) => (
@@ -163,10 +181,20 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
<Dropdown
trigger={["click"]}
menu={{
items: allColumns.map((column_id) => ({
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<IResourceComponentsProps> = () => {
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),

View File

@@ -16,4 +16,5 @@ export interface IFilament {
settings_extruder_temp?: number;
settings_bed_temp?: number;
color_hex?: string;
extra: { [key: string]: string };
}

View File

@@ -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<IResourceComponentsProps> = () => {
const t = useTranslate();
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.filament);
const { queryResult } = useShow<IFilament>({
liveMode: "auto",
});
@@ -74,8 +77,6 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
/>
<Title level={5}>{t("filament.fields.name")}</Title>
<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>
<TextField value={record?.color_hex} />
<Title level={5}>{t("filament.fields.material")}</Title>
@@ -134,6 +135,9 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
<TextField value={record?.article_number} />
<Title level={5}>{t("filament.fields.comment")}</Title>
<TextField value={enrichText(record?.comment)} />
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))}
</Show>
);
};

View File

@@ -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<HTMLElement> {
record: FieldHolder;
@@ -194,19 +195,19 @@ const EditableCell: React.FC<EditableCellProps> = ({ record, editing, dataIndex,
type: "number",
});
} else if (fieldType === FieldType.integer_range) {
inputNode = <Input placeholder="Example: 180 - 210" />;
inputNode = <InputNumberRange precision={0} />;
rules.push({
type: "string",
pattern: /^-?\d+\s*-\s*-?\d+$/,
type: "array",
len: 2,
});
} else if (fieldType === FieldType.float_range) {
inputNode = <Input placeholder="Example: 1.34 - 2.90" />;
inputNode = <InputNumberRange precision={3} />;
rules.push({
type: "string",
pattern: /^-?\d+([.,]\d+)?\s*-\s*-?\d+([.,]\d+)?$/,
type: "array",
len: 2,
});
} else if (fieldType === FieldType.datetime) {
inputNode = <DatePicker format={dateTimeFormat} showTime={{ use12Hours: false }} />;
inputNode = <DateTimePicker />;
} else if (fieldType === FieldType.choice) {
inputNode = (
<Select
@@ -300,23 +301,12 @@ export function ExtraFieldsSettings() {
const edit = (record: Partial<Field> & { 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;
}

View File

@@ -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();

View File

@@ -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<IResourceComponentsProps> = () => {
tableState,
dataId: "filament.id",
filterValueQuery: useSpoolmanFilamentFilter(),
sorter: true,
}),
FilteredQueryColumn({
id: "filament.material",
@@ -315,6 +316,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
tableState,
filterValueQuery: useSpoolmanMaterials(),
width: 120,
sorter: true,
}),
SortedColumn({
id: "price",
@@ -328,43 +330,47 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
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<IResourceComponentsProps> = () => {
tableState,
filterValueQuery: useSpoolmanLocations(),
width: 120,
sorter: true,
}),
FilteredQueryColumn({
id: "lot_nr",
@@ -383,6 +390,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
tableState,
filterValueQuery: useSpoolmanLotNumbers(),
width: 120,
sorter: true,
}),
DateColumn({
id: "first_used",
@@ -390,6 +398,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
actions,
dataSource,
tableState,
sorter: true,
}),
DateColumn({
id: "last_used",
@@ -397,6 +406,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
actions,
dataSource,
tableState,
sorter: true,
}),
DateColumn({
id: "registered",
@@ -404,6 +414,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
actions,
dataSource,
tableState,
sorter: true,
}),
RichColumn({
id: "comment",
@@ -412,6 +423,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
dataSource,
tableState,
width: 150,
sorter: true,
}),
ActionsColumn(actions),
])}

View File

@@ -152,6 +152,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
actions,
dataSource,
tableState,
sorter: true,
}),
RichColumn({
id: "comment",
@@ -159,6 +160,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
actions,
dataSource,
tableState,
sorter: true,
}),
ActionsColumn<IVendor>(actions),
])}

View File

@@ -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;
}