Work on extra fields settings UI
Almost finished
This commit is contained in:
@@ -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": "<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 select state. If you remove a field, the associated data for all entities will be deleted.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
|
||||
"params": {
|
||||
"key": "Key",
|
||||
"name": "Name",
|
||||
"field_type": "Type",
|
||||
"unit": "Unit",
|
||||
"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": {
|
||||
|
||||
@@ -217,7 +217,7 @@ function App() {
|
||||
<Route path="edit/:id" element={<LoadableResourcePage resource="vendors" page="edit" />} />
|
||||
<Route path="show/:id" element={<LoadableResourcePage resource="vendors" page="show" />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<LoadablePage name="settings" />} />
|
||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||
<Route path="*" element={<ErrorComponent />} />
|
||||
</Route>
|
||||
|
||||
@@ -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<HTMLElement> {
|
||||
record: FieldHolder;
|
||||
editing: boolean;
|
||||
dataIndex: string;
|
||||
form: FormInstance;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ColumnProps extends ColumnType<FieldHolder> {
|
||||
editable: boolean;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface FieldHolder {
|
||||
key: string;
|
||||
field: Field;
|
||||
is_new: boolean;
|
||||
}
|
||||
|
||||
const EditableCell: React.FC<EditableCellProps> = ({ record, editing, dataIndex, children, form, ...restProps }) => {
|
||||
const t = useTranslate();
|
||||
|
||||
if (!editing) {
|
||||
return <td {...restProps}>{children}</td>;
|
||||
}
|
||||
|
||||
const fieldType = form.getFieldValue("field_type") as FieldType;
|
||||
const choices = form.getFieldValue("choices") as string[];
|
||||
|
||||
const title = t(`settings.extra_fields.params.${dataIndex}`);
|
||||
|
||||
let inputNode;
|
||||
const rules: Rule[] = [];
|
||||
const formItemProps: FormItemProps = {};
|
||||
if (dataIndex === "key") {
|
||||
inputNode = <Input />;
|
||||
rules.push({
|
||||
required: true,
|
||||
min: 1,
|
||||
pattern: /^[a-z0-9_]+$/,
|
||||
});
|
||||
rules.push({
|
||||
validator: async (_, value) => {
|
||||
// Ensure key is not new_field
|
||||
if (value === "new_field") {
|
||||
throw new Error(t("settings.extra_fields.key_not_changed"));
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (dataIndex === "field_type") {
|
||||
inputNode = (
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.text}`),
|
||||
value: FieldType.text,
|
||||
},
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.integer}`),
|
||||
value: FieldType.integer,
|
||||
},
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.integer_range}`),
|
||||
value: FieldType.integer_range,
|
||||
},
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.float}`),
|
||||
value: FieldType.float,
|
||||
},
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.float_range}`),
|
||||
value: FieldType.float_range,
|
||||
},
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.datetime}`),
|
||||
value: FieldType.datetime,
|
||||
},
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.boolean}`),
|
||||
value: FieldType.boolean,
|
||||
},
|
||||
{
|
||||
label: t(`settings.extra_fields.field_type.${FieldType.choice}`),
|
||||
value: FieldType.choice,
|
||||
},
|
||||
]}
|
||||
onChange={() => {
|
||||
// Reset default_value when changing field_type
|
||||
form.setFieldsValue({
|
||||
default_value: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
rules.push({
|
||||
required: true,
|
||||
});
|
||||
} else if (dataIndex === "name") {
|
||||
inputNode = <Input />;
|
||||
rules.push({
|
||||
required: true,
|
||||
min: 1,
|
||||
});
|
||||
} else if (dataIndex === "unit") {
|
||||
if (
|
||||
fieldType === FieldType.integer ||
|
||||
fieldType === FieldType.integer_range ||
|
||||
fieldType === FieldType.float ||
|
||||
fieldType === FieldType.float_range
|
||||
) {
|
||||
inputNode = <Input />;
|
||||
} else {
|
||||
inputNode = null;
|
||||
}
|
||||
rules.push({
|
||||
required: false,
|
||||
});
|
||||
} else if (dataIndex === "default_value") {
|
||||
if (fieldType === FieldType.boolean) {
|
||||
inputNode = <Checkbox />;
|
||||
formItemProps.valuePropName = "checked";
|
||||
rules.push({
|
||||
type: "boolean",
|
||||
});
|
||||
} else if (fieldType === FieldType.text) {
|
||||
inputNode = <Input />;
|
||||
rules.push({
|
||||
type: "string",
|
||||
});
|
||||
} else if (fieldType === FieldType.integer) {
|
||||
inputNode = <InputNumber />;
|
||||
rules.push({
|
||||
type: "integer",
|
||||
});
|
||||
} else if (fieldType === FieldType.float) {
|
||||
inputNode = <InputNumber />;
|
||||
rules.push({
|
||||
type: "float",
|
||||
});
|
||||
} else if (fieldType === FieldType.integer_range) {
|
||||
inputNode = <Input placeholder="Example: 180 - 210" />;
|
||||
rules.push({
|
||||
type: "string",
|
||||
pattern: /^-?\d+\s*-\s*-?\d+$/,
|
||||
});
|
||||
} else if (fieldType === FieldType.float_range) {
|
||||
inputNode = <Input placeholder="Example: 1.34 - 2.90" />;
|
||||
rules.push({
|
||||
type: "string",
|
||||
pattern: /^-?\d+([.,]\d+)?\s*-\s*-?\d+([.,]\d+)?$/,
|
||||
});
|
||||
} else if (fieldType === FieldType.datetime) {
|
||||
inputNode = <DatePicker format="YYYY-MM-DD hh:mm:ss" showTime={{ use12Hours: false }} />;
|
||||
} else if (fieldType === FieldType.choice) {
|
||||
inputNode = (
|
||||
<Select
|
||||
mode={form.getFieldValue("multi_choice") ? "multiple" : undefined}
|
||||
options={choices.map((choice) => ({ label: choice, value: choice }))}
|
||||
/>
|
||||
);
|
||||
rules.push({
|
||||
type: form.getFieldValue("multi_choice") ? "array" : "string",
|
||||
});
|
||||
}
|
||||
} else if (dataIndex === "choices") {
|
||||
if (fieldType === FieldType.choice) {
|
||||
inputNode = <Select mode="tags" tokenSeparators={[","]} open={false} />;
|
||||
rules.push({
|
||||
required: true,
|
||||
min: 1,
|
||||
type: "array",
|
||||
validator: async (_, value) => {
|
||||
// Verify that all values in record.choices are in value
|
||||
const recordChoices = record.field.choices || [];
|
||||
const valueChoices = value || [];
|
||||
const missingChoices = recordChoices.filter((choice) => !valueChoices.includes(choice));
|
||||
if (missingChoices.length > 0) {
|
||||
throw new Error(
|
||||
t("settings.extra_fields.choices_missing_error", {
|
||||
choices: missingChoices.join(", "),
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
inputNode = null;
|
||||
}
|
||||
} else if (dataIndex === "multi_choice") {
|
||||
if (fieldType === FieldType.choice) {
|
||||
inputNode = (
|
||||
<Checkbox
|
||||
onChange={() => {
|
||||
// Reset default_value when changing multi_choice
|
||||
form.setFieldsValue({
|
||||
default_value: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Checkbox>
|
||||
);
|
||||
formItemProps.valuePropName = "checked";
|
||||
rules.push({
|
||||
type: "boolean",
|
||||
});
|
||||
} else {
|
||||
inputNode = null;
|
||||
}
|
||||
} else {
|
||||
inputNode = null;
|
||||
}
|
||||
|
||||
const formItem = inputNode ? (
|
||||
<Form.Item
|
||||
name={dataIndex}
|
||||
messageVariables={{ label: title }}
|
||||
style={{ margin: 0 }}
|
||||
rules={rules}
|
||||
{...formItemProps}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
) : null;
|
||||
|
||||
return <td {...restProps}>{formItem}</td>;
|
||||
};
|
||||
|
||||
export function ExtraFieldsSettings() {
|
||||
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<FieldHolder | null>(null);
|
||||
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const [editingKey, setEditingKey] = useState("");
|
||||
|
||||
const isEditing = (record: FieldHolder) => record.field.key === editingKey;
|
||||
|
||||
const edit = (record: Partial<Field> & { key: React.Key }) => {
|
||||
const values = { ...record };
|
||||
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 ? (
|
||||
<span>
|
||||
<Typography.Link onClick={() => save(record.field.key)} style={{ marginRight: 8 }}>
|
||||
Save
|
||||
</Typography.Link>
|
||||
<Typography.Link onClick={() => cancel()}>Cancel</Typography.Link>
|
||||
</span>
|
||||
) : (
|
||||
<Typography.Link disabled={editingKey !== ""} onClick={() => edit(record.field)}>
|
||||
Edit
|
||||
</Typography.Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h3>
|
||||
{t("settings.extra_fields.tab")} - {niceName}
|
||||
</h3>
|
||||
<Trans
|
||||
i18nKey={"settings.extra_fields.description"}
|
||||
components={{
|
||||
p: <p />,
|
||||
}}
|
||||
/>
|
||||
<Form form={form} component={false} disabled={isSubmitting}>
|
||||
<Table
|
||||
components={{
|
||||
body: {
|
||||
cell: EditableCell,
|
||||
},
|
||||
}}
|
||||
columns={mergedColumns}
|
||||
dataSource={tableFields}
|
||||
loading={fields.isLoading}
|
||||
rowClassName="editable-row"
|
||||
pagination={false}
|
||||
/>
|
||||
</Form>
|
||||
{newField == null && (
|
||||
<Flex justify="center">
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
size="large"
|
||||
style={{
|
||||
margin: "1em",
|
||||
}}
|
||||
onClick={() => {
|
||||
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");
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label={t("settings.generic.currency.label")}
|
||||
label={t("settings.general.currency.label")}
|
||||
name="currency"
|
||||
rules={[
|
||||
{
|
||||
@@ -4,10 +4,10 @@ import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { Content } from "antd/es/layout/layout";
|
||||
import { Menu, theme } from "antd";
|
||||
import { SolutionOutlined, ToolOutlined } from "@ant-design/icons";
|
||||
import { useSavedState } from "../../utils/saveload";
|
||||
import { GenericSettings } from "./GenericSettings";
|
||||
import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { ExtraFieldsSettings } from "./ExtraFieldsSettings";
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -16,7 +16,13 @@ const { useToken } = theme;
|
||||
export const Settings: React.FC<IResourceComponentsProps> = () => {
|
||||
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<IResourceComponentsProps> = () => {
|
||||
>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
selectedKeys={[current]}
|
||||
onClick={({ key }) => 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: <ToolOutlined /> },
|
||||
{ key: "extra", label: t("settings.extra_fields.tab"), icon: <SolutionOutlined /> },
|
||||
{ key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> },
|
||||
{
|
||||
key: "extra",
|
||||
label: t("settings.extra_fields.tab"),
|
||||
icon: <SolutionOutlined />,
|
||||
children: [
|
||||
{
|
||||
label: t("spool.spool"),
|
||||
key: "extra/spool",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
label: t("filament.filament"),
|
||||
key: "extra/filament",
|
||||
icon: <HighlightOutlined />,
|
||||
},
|
||||
{
|
||||
label: t("vendor.vendor"),
|
||||
key: "extra/vendor",
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
style={{
|
||||
marginBottom: "3em",
|
||||
marginBottom: "1em",
|
||||
}}
|
||||
/>
|
||||
<main>
|
||||
{current === "generic" && <GenericSettings />}
|
||||
{current === "extra" && <ExtraFieldsSettings />}
|
||||
<Routes>
|
||||
<Route index element={<GeneralSettings />} />
|
||||
<Route path="/extra/:entityType" element={<ExtraFieldsSettings />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</Content>
|
||||
</>
|
||||
|
||||
130
client/src/pages/settings/queryFields.ts
Normal file
130
client/src/pages/settings/queryFields.ts
Normal file
@@ -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<Field[]>({
|
||||
queryKey: ["fields", entity_type],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${apiEndpoint}/field/${entity_type}`);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetField(entity_type: EntityType) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useMutation<Field[], unknown, { key: string; params: FieldParameters }, { previousFields?: Field[] }>({
|
||||
mutationFn: async ({ key, params }) => {
|
||||
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
// Throw error if response is not ok
|
||||
if (!response.ok) {
|
||||
throw new Error((await response.json()).message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onMutate: async ({ key, params }) => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries(["fields", entity_type]);
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousFields = queryClient.getQueryData<Field[]>(["fields", entity_type]);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData<Field[]>(["fields", entity_type], (old) => {
|
||||
if (!old) {
|
||||
return [
|
||||
{
|
||||
key: key,
|
||||
entity_type: entity_type,
|
||||
...params,
|
||||
},
|
||||
];
|
||||
}
|
||||
return old.map((field) => {
|
||||
if (field.key === key) {
|
||||
return { ...field, ...params };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
});
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousFields };
|
||||
},
|
||||
onError: (_err, _newFields, context) => {
|
||||
// Rollback to the previous value
|
||||
if (context?.previousFields) {
|
||||
queryClient.setQueryData(["fields", entity_type], context.previousFields);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(["fields", entity_type]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteField(entity_type: EntityType) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const apiEndpoint = import.meta.env.VITE_APIURL;
|
||||
return useMutation<Field[], unknown, string>({
|
||||
mutationFn: async (key) => {
|
||||
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Throw error if response is not ok
|
||||
if (!response.ok) {
|
||||
throw new Error((await response.json()).message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(["fields", entity_type]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }) => {
|
||||
Reference in New Issue
Block a user