diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 23d06e0..166a062 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -42,7 +42,8 @@
"editSuccess": "Successfully edited {{resource}}",
"editError": "Error when editing {{resource}} (status code: {{statusCode}})",
"importProgress": "Importing: {{processed}}/{{total}}",
- "saveSuccessful": "Save successful!"
+ "saveSuccessful": "Save successful!",
+ "validationError": "Validation error: {{error}}"
},
"kofi": "Tip me on Ko-fi",
"loading": "Loading",
@@ -255,14 +256,39 @@
},
"settings": {
"header": "Settings",
- "generic": {
- "tab": "Generic",
+ "general": {
+ "tab": "General",
"currency": {
"label": "Currency"
}
},
"extra_fields": {
- "tab": "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 select state. If you remove a field, the associated data for all entities will be deleted.
Extra fields can not be sorted or filtered in the table views.
",
+ "params": {
+ "key": "Key",
+ "name": "Name",
+ "field_type": "Type",
+ "unit": "Unit",
+ "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."
}
},
"documentTitle": {
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 550fbcb..fbcddf3 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -217,7 +217,7 @@ function App() {
} />
} />
- } />
+ } />
} />
} />
diff --git a/client/src/pages/settings/ExtraFieldsSettings.tsx b/client/src/pages/settings/ExtraFieldsSettings.tsx
index 95bbec5..10653b4 100644
--- a/client/src/pages/settings/ExtraFieldsSettings.tsx
+++ b/client/src/pages/settings/ExtraFieldsSettings.tsx
@@ -1,5 +1,554 @@
-import React from "react";
+import {
+ Button,
+ Checkbox,
+ DatePicker,
+ Flex,
+ Form,
+ FormInstance,
+ Input,
+ InputNumber,
+ Select,
+ Table,
+ Typography,
+ message,
+} from "antd";
+import { EntityType, Field, FieldType, useGetFields, useSetField } from "./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";
+
+interface EditableCellProps extends React.HTMLAttributes {
+ record: FieldHolder;
+ editing: boolean;
+ dataIndex: string;
+ form: FormInstance;
+ children: React.ReactNode;
+}
+
+interface ColumnProps extends ColumnType {
+ editable: boolean;
+ required: boolean;
+}
+
+interface FieldHolder {
+ key: string;
+ field: Field;
+ is_new: boolean;
+}
+
+const EditableCell: React.FC = ({ record, editing, dataIndex, children, form, ...restProps }) => {
+ const t = useTranslate();
+
+ if (!editing) {
+ return {children} ;
+ }
+
+ 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 = ;
+ rules.push({
+ required: true,
+ min: 1,
+ 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 = (
+ {
+ // Reset default_value when changing field_type
+ form.setFieldsValue({
+ default_value: undefined,
+ });
+ }}
+ />
+ );
+ rules.push({
+ required: true,
+ });
+ } else if (dataIndex === "name") {
+ inputNode = ;
+ rules.push({
+ required: true,
+ min: 1,
+ });
+ } else if (dataIndex === "unit") {
+ if (
+ fieldType === FieldType.integer ||
+ fieldType === FieldType.integer_range ||
+ fieldType === FieldType.float ||
+ fieldType === FieldType.float_range
+ ) {
+ inputNode = ;
+ } else {
+ inputNode = null;
+ }
+ rules.push({
+ required: false,
+ });
+ } else if (dataIndex === "default_value") {
+ if (fieldType === FieldType.boolean) {
+ inputNode = ;
+ formItemProps.valuePropName = "checked";
+ rules.push({
+ type: "boolean",
+ });
+ } else if (fieldType === FieldType.text) {
+ inputNode = ;
+ rules.push({
+ type: "string",
+ });
+ } else if (fieldType === FieldType.integer) {
+ inputNode = ;
+ rules.push({
+ type: "integer",
+ });
+ } else if (fieldType === FieldType.float) {
+ inputNode = ;
+ rules.push({
+ type: "float",
+ });
+ } else if (fieldType === FieldType.integer_range) {
+ inputNode = ;
+ rules.push({
+ type: "string",
+ pattern: /^-?\d+\s*-\s*-?\d+$/,
+ });
+ } else if (fieldType === FieldType.float_range) {
+ inputNode = ;
+ rules.push({
+ type: "string",
+ pattern: /^-?\d+([.,]\d+)?\s*-\s*-?\d+([.,]\d+)?$/,
+ });
+ } else if (fieldType === FieldType.datetime) {
+ inputNode = ;
+ } else if (fieldType === FieldType.choice) {
+ inputNode = (
+ ({ label: choice, value: choice }))}
+ />
+ );
+ rules.push({
+ type: form.getFieldValue("multi_choice") ? "array" : "string",
+ });
+ }
+ } else if (dataIndex === "choices") {
+ if (fieldType === FieldType.choice) {
+ inputNode = ;
+ 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 = (
+ {
+ // Reset default_value when changing multi_choice
+ form.setFieldsValue({
+ default_value: undefined,
+ });
+ }}
+ >
+ {title}
+
+ );
+ formItemProps.valuePropName = "checked";
+ rules.push({
+ type: "boolean",
+ });
+ } else {
+ inputNode = null;
+ }
+ } else {
+ inputNode = null;
+ }
+
+ const formItem = inputNode ? (
+
+ {inputNode}
+
+ ) : null;
+
+ return {formItem} ;
+};
export function ExtraFieldsSettings() {
- return <>Extra Fields Settings Content>;
+ 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 [isSubmitting, setIsSubmitting] = useState(false);
+ const [newField, setNewField] = useState(null);
+
+ const [messageApi, contextHolder] = message.useMessage();
+
+ const [editingKey, setEditingKey] = useState("");
+
+ const isEditing = (record: FieldHolder) => record.field.key === editingKey;
+
+ const edit = (record: Partial & { key: React.Key }) => {
+ const values = { ...record };
+ console.log(values);
+ if (values.default_value) {
+ values.default_value = JSON.parse(values.default_value);
+ } else {
+ values.default_value = "";
+ }
+ form.setFieldsValue(values);
+ setEditingKey(record.key);
+ };
+
+ const cancel = () => {
+ setEditingKey("");
+ };
+
+ const save = async (_key: React.Key) => {
+ let row;
+ try {
+ row = (await form.validateFields()) as Field;
+ } catch (errInfo) {
+ // Ignore these errors because they are already handled by the form
+ return;
+ }
+
+ // Do some value conversions
+ try {
+ // Convert float and integer range to array using the validation regex
+ if (row.field_type === FieldType.float_range && typeof row.default_value === "string") {
+ const pattern = /^(-?\d+(?:[.,]\d+)?)\s*-\s*(-?\d+(?:[.,]\d+)?)$/;
+ const matches = row.default_value.match(pattern);
+ if (matches) {
+ const val1 = parseFloat(matches[1].replace(",", "."));
+ const val2 = parseFloat(matches[2].replace(",", "."));
+ row.default_value = JSON.stringify([Math.min(val1, val2), Math.max(val1, val2)]);
+ }
+ } else if (row.field_type === FieldType.integer_range && typeof row.default_value === "string") {
+ const pattern = /^(-?\d+)\s*-\s*(-?\d+)$/;
+ const matches = row.default_value.match(pattern);
+ if (matches) {
+ const val1 = parseInt(matches[1]);
+ const val2 = parseInt(matches[2]);
+ row.default_value = JSON.stringify([Math.min(val1, val2), Math.max(val1, val2)]);
+ }
+ } else {
+ // Just stringify all other values
+ row.default_value = JSON.stringify(row.default_value);
+ }
+
+ // Set multi_choice if it's not set and field_type is choice
+ if (row.field_type === FieldType.choice && row.multi_choice === undefined) {
+ row.multi_choice = false;
+ }
+
+ // If unit is an empty string, set it to null instead
+ if (row.unit === "") {
+ row.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 row.key is unique and not among the other keys
+ const keys = new Set(fields.data?.map((field) => field.key) || []);
+ if (keys.has(row.key)) {
+ messageApi.error(t("settings.extra_fields.non_unique_key_error"));
+ return;
+ }
+
+ // Submit it!
+ try {
+ console.log(row);
+
+ setIsSubmitting(true);
+
+ setField.reset();
+
+ await setField.mutateAsync({
+ key: row.key,
+ params: row,
+ });
+
+ setEditingKey("");
+ setNewField(null);
+ } catch (errInfo) {
+ if (errInfo instanceof Error) {
+ messageApi.error(errInfo.message);
+ }
+ }
+ setIsSubmitting(false);
+ };
+
+ const niceName = t(`${entityType}.${entityType}`);
+
+ const columns: ColumnProps[] = [
+ {
+ title: t("settings.extra_fields.params.key"),
+ dataIndex: ["field", "key"],
+ key: "key",
+ editable: false,
+ required: true,
+ },
+ {
+ title: t("settings.extra_fields.params.name"),
+ dataIndex: ["field", "name"],
+ editable: true,
+ required: true,
+ },
+ {
+ title: t("settings.extra_fields.params.field_type"),
+ dataIndex: ["field", "field_type"],
+ editable: false,
+ required: true,
+ render(value) {
+ return t(`settings.extra_fields.field_type.${value}`);
+ },
+ },
+ {
+ title: t("settings.extra_fields.params.unit"),
+ dataIndex: ["field", "unit"],
+ editable: true,
+ required: false,
+ },
+ {
+ title: t("settings.extra_fields.params.default_value"),
+ dataIndex: ["field", "default_value"],
+ editable: true,
+ required: false,
+ 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 === "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]} - ${val[1]}`;
+ } else {
+ return null;
+ }
+ },
+ },
+ {
+ title: t("settings.extra_fields.params.choices"),
+ dataIndex: ["field", "choices"],
+ editable: true,
+ required: false,
+ render(value, record) {
+ if (record.field.field_type === FieldType.choice && Array.isArray(value)) {
+ return value.join(", ");
+ } else {
+ return null;
+ }
+ },
+ },
+ {
+ title: t("settings.extra_fields.params.multi_choice"),
+ dataIndex: ["field", "multi_choice"],
+ editable: false,
+ required: false,
+ 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;
+ }
+ },
+ },
+ {
+ title: "",
+ dataIndex: "operation",
+ editable: false,
+ required: false,
+ render: (_: unknown, record: FieldHolder) => {
+ const editable = isEditing(record);
+ return editable ? (
+
+ save(record.field.key)} style={{ marginRight: 8 }}>
+ Save
+
+ cancel()}>Cancel
+
+ ) : (
+ edit(record.field)}>
+ Edit
+
+ );
+ },
+ },
+ ];
+
+ 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 || []).map((field) => ({
+ key: field.key,
+ field,
+ is_new: false,
+ })),
+ ...(newField ? [newField] : []),
+ ];
+
+ return (
+ <>
+
+ {t("settings.extra_fields.tab")} - {niceName}
+
+ ,
+ }}
+ />
+
+ {newField == null && (
+
+ }
+ size="large"
+ style={{
+ margin: "1em",
+ }}
+ onClick={() => {
+ const newFieldData: Field = {
+ key: "new_field",
+ name: "",
+ entity_type: entityType as EntityType,
+ field_type: FieldType.text,
+ unit: "",
+ default_value: "",
+ choices: [],
+ multi_choice: false,
+ };
+
+ setNewField({
+ key: "new_field",
+ field: newFieldData,
+ is_new: true,
+ });
+ form.setFieldsValue(newFieldData);
+ setEditingKey("new_field");
+ }}
+ />
+
+ )}
+ {contextHolder}
+ >
+ );
}
diff --git a/client/src/pages/settings/GenericSettings.tsx b/client/src/pages/settings/GeneralSettings.tsx
similarity index 92%
rename from client/src/pages/settings/GenericSettings.tsx
rename to client/src/pages/settings/GeneralSettings.tsx
index f0e1adc..48ca193 100644
--- a/client/src/pages/settings/GenericSettings.tsx
+++ b/client/src/pages/settings/GeneralSettings.tsx
@@ -1,9 +1,9 @@
import React from "react";
import { useTranslate } from "@refinedev/core";
import { Button, Form, Input, message } from "antd";
-import { useGetSettings, useSetSetting } from "./query";
+import { useGetSettings, useSetSetting } from "./querySettings";
-export function GenericSettings() {
+export function GeneralSettings() {
const settings = useGetSettings();
const setSetting = useSetSetting();
const [form] = Form.useForm();
@@ -53,7 +53,7 @@ export function GenericSettings() {
}}
>
= () => {
const { token } = useToken();
const t = useTranslate();
- const [current, setCurrent] = useSavedState("settings-tab", "generic");
+ 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 (
<>
@@ -42,19 +48,48 @@ export const Settings: React.FC = () => {
>
setCurrent(key)}
+ selectedKeys={[getCurrentKey()]}
+ onClick={(e) => {
+ if (e.key === "") {
+ return navigate("/settings");
+ } else {
+ return navigate(`/settings/${e.key}`);
+ }
+ }}
items={[
- { key: "generic", label: t("settings.generic.tab"), icon: },
- { key: "extra", label: t("settings.extra_fields.tab"), icon: },
+ { key: "", label: t("settings.general.tab"), icon: },
+ {
+ key: "extra",
+ label: t("settings.extra_fields.tab"),
+ icon: ,
+ children: [
+ {
+ label: t("spool.spool"),
+ key: "extra/spool",
+ icon: ,
+ },
+ {
+ label: t("filament.filament"),
+ key: "extra/filament",
+ icon: ,
+ },
+ {
+ label: t("vendor.vendor"),
+ key: "extra/vendor",
+ icon: ,
+ },
+ ],
+ },
]}
style={{
- marginBottom: "3em",
+ marginBottom: "1em",
}}
/>
- {current === "generic" && }
- {current === "extra" && }
+
+ } />
+ } />
+
>
diff --git a/client/src/pages/settings/queryFields.ts b/client/src/pages/settings/queryFields.ts
new file mode 100644
index 0000000..f6e01a9
--- /dev/null
+++ b/client/src/pages/settings/queryFields.ts
@@ -0,0 +1,130 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+
+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;
+ unit?: string;
+ field_type: FieldType;
+ default_value?: string;
+ 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({
+ 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({
+ 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(["fields", entity_type]);
+
+ // Optimistically update to the new value
+ queryClient.setQueryData(["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({
+ 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]);
+ },
+ });
+}
diff --git a/client/src/pages/settings/query.ts b/client/src/pages/settings/querySettings.ts
similarity index 91%
rename from client/src/pages/settings/query.ts
rename to client/src/pages/settings/querySettings.ts
index 63ce94a..87b8ade 100644
--- a/client/src/pages/settings/query.ts
+++ b/client/src/pages/settings/querySettings.ts
@@ -45,6 +45,12 @@ export function useSetSetting() {
},
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 }) => {