637 lines
18 KiB
TypeScript
637 lines
18 KiB
TypeScript
import {
|
|
Button,
|
|
Checkbox,
|
|
Flex,
|
|
Form,
|
|
FormInstance,
|
|
Input,
|
|
InputNumber,
|
|
Popconfirm,
|
|
Select,
|
|
Space,
|
|
Table,
|
|
message,
|
|
} from "antd";
|
|
import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields";
|
|
import { useTranslate } from "@refinedev/core";
|
|
import { useState } from "react";
|
|
import { ColumnType } from "antd/es/table";
|
|
import { Trans } from "react-i18next";
|
|
import { useParams } from "react-router-dom";
|
|
import { FormItemProps, Rule } from "antd/es/form";
|
|
import { PlusOutlined } from "@ant-design/icons";
|
|
import dayjs from "dayjs";
|
|
import utc from "dayjs/plugin/utc";
|
|
import timezone from "dayjs/plugin/timezone";
|
|
import advancedFormat from "dayjs/plugin/advancedFormat";
|
|
import { DateTimePicker } from "../../components/dateTimePicker";
|
|
import { InputNumberRange } from "../../components/inputNumberRange";
|
|
|
|
dayjs.extend(utc);
|
|
dayjs.extend(timezone);
|
|
dayjs.extend(advancedFormat);
|
|
|
|
// Localized date time format with timezone
|
|
const dateTimeFormat = "YYYY-MM-DD HH:mm:ss";
|
|
|
|
interface EditableCellProps extends React.HTMLAttributes<HTMLElement> {
|
|
record: FieldHolder;
|
|
editing: boolean;
|
|
dataIndex: string;
|
|
form: FormInstance;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
interface FieldHolder {
|
|
key: string;
|
|
field: Field;
|
|
is_new: boolean;
|
|
}
|
|
|
|
const canEditField = (dataIndex: string, isNew: boolean) => {
|
|
if (isNew) {
|
|
return true;
|
|
}
|
|
return dataIndex !== "key" && dataIndex !== "field_type" && dataIndex !== "multi_choice";
|
|
};
|
|
|
|
const EditableCell: React.FC<EditableCellProps> = ({ record, editing, dataIndex, children, form, ...restProps }) => {
|
|
const t = useTranslate();
|
|
|
|
if (!editing || !canEditField(dataIndex, record.is_new)) {
|
|
return (
|
|
<td
|
|
{...restProps}
|
|
style={{
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{children}
|
|
</td>
|
|
);
|
|
}
|
|
|
|
const fieldType = form.getFieldValue("field_type") as FieldType;
|
|
const choices = form.getFieldValue("choices") as string[];
|
|
|
|
const title = t(`settings.extra_fields.params.${dataIndex}`);
|
|
|
|
let inputNode;
|
|
const rules: Rule[] = [];
|
|
const formItemProps: FormItemProps = {};
|
|
if (dataIndex === "key") {
|
|
inputNode = <Input />;
|
|
rules.push({
|
|
required: true,
|
|
min: 1,
|
|
max: 64,
|
|
pattern: /^[a-z0-9_]+$/,
|
|
});
|
|
rules.push({
|
|
validator: async (_, value) => {
|
|
// Ensure key is not new_field
|
|
if (value === "new_field") {
|
|
throw new Error(t("settings.extra_fields.key_not_changed"));
|
|
}
|
|
},
|
|
});
|
|
} else if (dataIndex === "field_type") {
|
|
inputNode = (
|
|
<Select
|
|
options={[
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.text}`),
|
|
value: FieldType.text,
|
|
},
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.integer}`),
|
|
value: FieldType.integer,
|
|
},
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.integer_range}`),
|
|
value: FieldType.integer_range,
|
|
},
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.float}`),
|
|
value: FieldType.float,
|
|
},
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.float_range}`),
|
|
value: FieldType.float_range,
|
|
},
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.datetime}`),
|
|
value: FieldType.datetime,
|
|
},
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.boolean}`),
|
|
value: FieldType.boolean,
|
|
},
|
|
{
|
|
label: t(`settings.extra_fields.field_type.${FieldType.choice}`),
|
|
value: FieldType.choice,
|
|
},
|
|
]}
|
|
onChange={() => {
|
|
// Reset default_value when changing field_type
|
|
form.setFieldsValue({
|
|
default_value: undefined,
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
rules.push({
|
|
required: true,
|
|
});
|
|
} else if (dataIndex === "name") {
|
|
inputNode = <Input />;
|
|
rules.push({
|
|
required: true,
|
|
min: 1,
|
|
max: 128,
|
|
});
|
|
} else if (dataIndex === "order") {
|
|
inputNode = <InputNumber style={{ width: "60px" }} />;
|
|
rules.push({
|
|
required: true,
|
|
min: 0,
|
|
type: "integer",
|
|
});
|
|
} else if (dataIndex === "unit") {
|
|
if (
|
|
fieldType === FieldType.integer ||
|
|
fieldType === FieldType.integer_range ||
|
|
fieldType === FieldType.float ||
|
|
fieldType === FieldType.float_range
|
|
) {
|
|
inputNode = <Input style={{ width: "60px" }} />;
|
|
} else {
|
|
inputNode = null;
|
|
}
|
|
rules.push({
|
|
required: false,
|
|
max: 16,
|
|
});
|
|
} else if (dataIndex === "default_value") {
|
|
if (fieldType === FieldType.boolean) {
|
|
inputNode = <Checkbox />;
|
|
formItemProps.valuePropName = "checked";
|
|
rules.push({
|
|
type: "boolean",
|
|
});
|
|
} else if (fieldType === FieldType.text) {
|
|
inputNode = <Input />;
|
|
rules.push({
|
|
type: "string",
|
|
});
|
|
} else if (fieldType === FieldType.integer) {
|
|
inputNode = <InputNumber />;
|
|
rules.push({
|
|
type: "integer",
|
|
});
|
|
} else if (fieldType === FieldType.float) {
|
|
inputNode = <InputNumber />;
|
|
rules.push({
|
|
type: "number",
|
|
});
|
|
} else if (fieldType === FieldType.integer_range) {
|
|
inputNode = <InputNumberRange precision={0} />;
|
|
rules.push({
|
|
type: "array",
|
|
len: 2,
|
|
});
|
|
} else if (fieldType === FieldType.float_range) {
|
|
inputNode = <InputNumberRange precision={3} />;
|
|
rules.push({
|
|
type: "array",
|
|
len: 2,
|
|
});
|
|
} else if (fieldType === FieldType.datetime) {
|
|
inputNode = <DateTimePicker />;
|
|
} else if (fieldType === FieldType.choice) {
|
|
inputNode = (
|
|
<Select
|
|
mode={form.getFieldValue("multi_choice") ? "multiple" : undefined}
|
|
options={choices.map((choice) => ({ label: choice, value: choice }))}
|
|
/>
|
|
);
|
|
rules.push({
|
|
type: form.getFieldValue("multi_choice") ? "array" : "string",
|
|
});
|
|
}
|
|
} else if (dataIndex === "choices") {
|
|
if (fieldType === FieldType.choice) {
|
|
inputNode = <Select mode="tags" tokenSeparators={[","]} open={false} />;
|
|
rules.push({
|
|
required: true,
|
|
min: 1,
|
|
type: "array",
|
|
validator: async (_, value) => {
|
|
// Verify that all values in record.choices are in value
|
|
const recordChoices = record.field.choices || [];
|
|
const valueChoices = value || [];
|
|
const missingChoices = recordChoices.filter((choice) => !valueChoices.includes(choice));
|
|
if (missingChoices.length > 0) {
|
|
throw new Error(
|
|
t("settings.extra_fields.choices_missing_error", {
|
|
choices: missingChoices.join(", "),
|
|
})
|
|
);
|
|
}
|
|
},
|
|
});
|
|
} else {
|
|
inputNode = null;
|
|
}
|
|
} else if (dataIndex === "multi_choice") {
|
|
if (fieldType === FieldType.choice) {
|
|
inputNode = (
|
|
<Checkbox
|
|
onChange={() => {
|
|
// Reset default_value when changing multi_choice
|
|
form.setFieldsValue({
|
|
default_value: undefined,
|
|
});
|
|
}}
|
|
>
|
|
{title}
|
|
</Checkbox>
|
|
);
|
|
formItemProps.valuePropName = "checked";
|
|
rules.push({
|
|
type: "boolean",
|
|
});
|
|
} else {
|
|
inputNode = null;
|
|
}
|
|
} else {
|
|
inputNode = null;
|
|
}
|
|
|
|
const formItem = inputNode ? (
|
|
<Form.Item
|
|
name={dataIndex}
|
|
messageVariables={{ label: title }}
|
|
style={{ margin: 0 }}
|
|
rules={rules}
|
|
{...formItemProps}
|
|
>
|
|
{inputNode}
|
|
</Form.Item>
|
|
) : null;
|
|
|
|
return <td {...restProps}>{formItem}</td>;
|
|
};
|
|
|
|
export function ExtraFieldsSettings() {
|
|
const { entityType } = useParams<{ entityType: EntityType }>();
|
|
const t = useTranslate();
|
|
const [form] = Form.useForm();
|
|
const fields = useGetFields(entityType as EntityType);
|
|
const setField = useSetField(entityType as EntityType);
|
|
const deleteField = useDeleteField(entityType as EntityType);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [newField, setNewField] = useState<FieldHolder | null>(null);
|
|
|
|
const [messageApi, contextHolder] = message.useMessage();
|
|
|
|
const [editingKey, setEditingKey] = useState("");
|
|
|
|
const isEditing = (record: FieldHolder) => record.field.key === editingKey;
|
|
|
|
const edit = (record: Partial<Field> & { key: React.Key }) => {
|
|
const values = { ...record };
|
|
|
|
if (values.default_value && typeof values.default_value === "string") {
|
|
const def = JSON.parse(values.default_value);
|
|
if (values.field_type === FieldType.boolean) {
|
|
values.default_value = def ? true : false;
|
|
} else {
|
|
values.default_value = def;
|
|
}
|
|
} else {
|
|
values.default_value = undefined;
|
|
}
|
|
form.setFieldsValue(values);
|
|
setEditingKey(record.key);
|
|
};
|
|
|
|
const cancel = () => {
|
|
setEditingKey("");
|
|
setNewField(null);
|
|
};
|
|
|
|
const del = async (field: Field) => {
|
|
try {
|
|
await deleteField.mutateAsync(field.key);
|
|
} catch (errInfo) {
|
|
if (errInfo instanceof Error) {
|
|
messageApi.error(errInfo.message);
|
|
}
|
|
}
|
|
};
|
|
|
|
const addNewField = () => {
|
|
// Calculate new order by getting the highest order and adding 1
|
|
const newOrder = Math.max(...(fields.data?.map((field) => field.order) || []), 0) + 1;
|
|
|
|
const newFieldData: Field = {
|
|
key: "new_field",
|
|
name: "",
|
|
entity_type: entityType as EntityType,
|
|
field_type: FieldType.text,
|
|
unit: "",
|
|
order: newOrder,
|
|
default_value: "",
|
|
choices: [],
|
|
multi_choice: false,
|
|
};
|
|
|
|
setNewField({
|
|
key: "new_field",
|
|
field: newFieldData,
|
|
is_new: true,
|
|
});
|
|
form.setFieldsValue(newFieldData);
|
|
setEditingKey("new_field");
|
|
};
|
|
|
|
const save = async (record: FieldHolder) => {
|
|
let row;
|
|
try {
|
|
row = (await form.validateFields()) as Field;
|
|
} catch (errInfo) {
|
|
// Ignore these errors because they are already handled by the form
|
|
return;
|
|
}
|
|
|
|
const updatedField = {
|
|
...record.field,
|
|
...row,
|
|
};
|
|
|
|
// Do some value conversions
|
|
try {
|
|
// Convert float and integer range to array using the validation regex
|
|
if (
|
|
(updatedField.field_type === FieldType.float_range || updatedField.field_type === FieldType.integer_range) &&
|
|
updatedField.default_value !== undefined
|
|
) {
|
|
if (Array.isArray(updatedField.default_value)) {
|
|
const val1 = updatedField.default_value[0];
|
|
const val2 = updatedField.default_value[1];
|
|
updatedField.default_value = JSON.stringify([val1, val2]);
|
|
} else {
|
|
console.warn("Invalid default_value for range", updatedField.default_value);
|
|
}
|
|
} else {
|
|
// Just stringify all other values
|
|
updatedField.default_value = JSON.stringify(updatedField.default_value);
|
|
}
|
|
|
|
// Set multi_choice if it's not set and field_type is choice
|
|
if (updatedField.field_type === FieldType.choice && updatedField.multi_choice === undefined) {
|
|
updatedField.multi_choice = false;
|
|
}
|
|
|
|
// If it's not choice, remove choices and multi_choice
|
|
if (updatedField.field_type !== FieldType.choice) {
|
|
updatedField.choices = undefined;
|
|
updatedField.multi_choice = undefined;
|
|
}
|
|
|
|
// If unit is an empty string, set it to null instead
|
|
if (updatedField.unit === "") {
|
|
updatedField.unit = undefined;
|
|
}
|
|
} catch (errInfo) {
|
|
if (errInfo instanceof Error) {
|
|
messageApi.error(t("notifications.validationError", { error: errInfo.message }));
|
|
// Check if errInfo has the errorFields property, then we should skip the error message
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Validate that updatedField.key is unique and not among the other keys
|
|
if (record.is_new) {
|
|
const keys = new Set(fields.data?.map((field) => field.key) || []);
|
|
if (keys.has(updatedField.key)) {
|
|
messageApi.error(t("settings.extra_fields.non_unique_key_error"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Submit it!
|
|
try {
|
|
setIsSubmitting(true);
|
|
|
|
setField.reset();
|
|
|
|
await setField.mutateAsync({
|
|
key: updatedField.key,
|
|
params: updatedField,
|
|
});
|
|
|
|
setEditingKey("");
|
|
setNewField(null);
|
|
} catch (errInfo) {
|
|
if (errInfo instanceof Error) {
|
|
messageApi.error(errInfo.message);
|
|
}
|
|
}
|
|
setIsSubmitting(false);
|
|
};
|
|
|
|
const niceName = t(`${entityType}.${entityType}`);
|
|
|
|
const columns: ColumnType<FieldHolder>[] = [
|
|
{
|
|
title: t("settings.extra_fields.params.key"),
|
|
dataIndex: ["field", "key"],
|
|
key: "key",
|
|
width: "10%",
|
|
},
|
|
{
|
|
title: t("settings.extra_fields.params.order"),
|
|
dataIndex: ["field", "order"],
|
|
key: "order",
|
|
width: "3%",
|
|
},
|
|
{
|
|
title: t("settings.extra_fields.params.name"),
|
|
dataIndex: ["field", "name"],
|
|
},
|
|
{
|
|
title: t("settings.extra_fields.params.field_type"),
|
|
dataIndex: ["field", "field_type"],
|
|
render(value) {
|
|
return t(`settings.extra_fields.field_type.${value}`);
|
|
},
|
|
width: "15%",
|
|
},
|
|
{
|
|
title: t("settings.extra_fields.params.unit"),
|
|
dataIndex: ["field", "unit"],
|
|
width: "6%",
|
|
},
|
|
{
|
|
title: t("settings.extra_fields.params.default_value"),
|
|
dataIndex: ["field", "default_value"],
|
|
render(value, record) {
|
|
const val = JSON.parse(value || "null");
|
|
if (typeof val === "boolean") {
|
|
return val ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false");
|
|
} else if (typeof val === "string" && record.field.field_type === FieldType.datetime) {
|
|
return dayjs(val).format(dateTimeFormat);
|
|
} else if (typeof val === "number" || typeof val === "string") {
|
|
return val;
|
|
} else if (Array.isArray(val) && record.field.field_type === FieldType.choice) {
|
|
return val.join(", ");
|
|
} else if (
|
|
(Array.isArray(val) && record.field.field_type === FieldType.integer_range) ||
|
|
record.field.field_type === FieldType.float_range
|
|
) {
|
|
return `${val[0]} \u2013 ${val[1]}`;
|
|
} else {
|
|
return null;
|
|
}
|
|
},
|
|
width: "15%",
|
|
},
|
|
{
|
|
title: t("settings.extra_fields.params.choices"),
|
|
dataIndex: ["field", "choices"],
|
|
render(value, record) {
|
|
if (record.field.field_type === FieldType.choice && Array.isArray(value)) {
|
|
return value.join(", ");
|
|
} else {
|
|
return null;
|
|
}
|
|
},
|
|
width: "15%",
|
|
},
|
|
{
|
|
title: t("settings.extra_fields.params.multi_choice"),
|
|
dataIndex: ["field", "multi_choice"],
|
|
render(value, record) {
|
|
if (record.field.field_type === FieldType.choice) {
|
|
return value ? t("settings.extra_fields.boolean_true") : t("settings.extra_fields.boolean_false");
|
|
} else {
|
|
return null;
|
|
}
|
|
},
|
|
width: "10%",
|
|
},
|
|
{
|
|
title: "",
|
|
dataIndex: "operation",
|
|
render: (_: unknown, record: FieldHolder) => {
|
|
const editing = isEditing(record);
|
|
return editing ? (
|
|
<Space>
|
|
<Button onClick={() => save(record)} size="small" type="primary">
|
|
{t("buttons.save")}
|
|
</Button>
|
|
<Button onClick={() => cancel()} size="small">
|
|
{t("buttons.cancel")}
|
|
</Button>
|
|
</Space>
|
|
) : (
|
|
<>
|
|
<Space>
|
|
<Button disabled={editingKey !== ""} onClick={() => edit(record.field)} size="small">
|
|
{t("buttons.edit")}
|
|
</Button>
|
|
<Popconfirm
|
|
title={t("settings.extra_fields.delete_confirm", { name: record.field.name })}
|
|
description={t("settings.extra_fields.delete_confirm_description", { name: record.field.name })}
|
|
onConfirm={() => del(record.field)}
|
|
disabled={editingKey !== ""}
|
|
okText={t("buttons.delete")}
|
|
cancelText={t("buttons.cancel")}
|
|
>
|
|
<Button disabled={editingKey !== ""} danger size="small">
|
|
{t("buttons.delete")}
|
|
</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
</>
|
|
);
|
|
},
|
|
width: "10%",
|
|
},
|
|
];
|
|
|
|
const mergedColumns = columns.map((col) => {
|
|
if (col.dataIndex === "operation") {
|
|
return col;
|
|
}
|
|
return {
|
|
...col,
|
|
onCell: function (record: FieldHolder): EditableCellProps {
|
|
return {
|
|
record,
|
|
editing: isEditing(record),
|
|
dataIndex: (col.dataIndex?.toString() || "").split(",").pop() || "",
|
|
form: form,
|
|
children: [],
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
const tableFields: FieldHolder[] = [
|
|
...(fields.data || [])
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((field) => ({
|
|
key: field.key,
|
|
field,
|
|
is_new: false,
|
|
})),
|
|
...(newField ? [newField] : []),
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<h3>
|
|
{t("settings.extra_fields.tab")} - {niceName}
|
|
</h3>
|
|
<Trans
|
|
i18nKey={"settings.extra_fields.description"}
|
|
components={{
|
|
p: <p />,
|
|
}}
|
|
/>
|
|
<Form form={form} component={false} disabled={isSubmitting}>
|
|
<Table
|
|
components={{
|
|
body: {
|
|
cell: EditableCell,
|
|
},
|
|
}}
|
|
columns={mergedColumns}
|
|
dataSource={tableFields}
|
|
loading={fields.isLoading}
|
|
rowClassName="editable-row"
|
|
pagination={false}
|
|
/>
|
|
</Form>
|
|
{newField == null && (
|
|
<Flex justify="center">
|
|
<Button
|
|
type="primary"
|
|
shape="circle"
|
|
icon={<PlusOutlined />}
|
|
size="large"
|
|
style={{
|
|
margin: "1em",
|
|
}}
|
|
onClick={() => addNewField()}
|
|
/>
|
|
</Flex>
|
|
)}
|
|
{contextHolder}
|
|
</>
|
|
);
|
|
}
|