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 { 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 = ({ record, editing, dataIndex, children, form, ...restProps }) => { const t = useTranslate(); if (!editing || !canEditField(dataIndex, record.is_new)) { 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, 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 = ( ; rules.push({ required: true, min: 1, max: 128, }); } else if (dataIndex === "order") { inputNode = ; 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 = ; } else { inputNode = null; } rules.push({ required: false, max: 16, }); } 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: "number", }); } else if (fieldType === FieldType.integer_range) { inputNode = ; rules.push({ type: "array", len: 2, }); } else if (fieldType === FieldType.float_range) { inputNode = ; rules.push({ type: "array", len: 2, }); } else if (fieldType === FieldType.datetime) { inputNode = ; } else 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() { 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(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 }; 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[] = [ { 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 ? ( ) : ( <> del(record.field)} disabled={editingKey !== ""} okText={t("buttons.delete")} cancelText={t("buttons.cancel")} > ); }, 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 ( <>

{t("settings.extra_fields.tab")} - {niceName}

, }} />
{newField == null && (